diff --git a/.github/workflows/android-app.yml b/.github/workflows/android-app.yml
index 962b811cc69b..42d4e04a5ef6 100644
--- a/.github/workflows/android-app.yml
+++ b/.github/workflows/android-app.yml
@@ -59,9 +59,6 @@ on:
 
 permissions: {}
 
-env:
-  E2E_TEST_INFRA_FLAVOR: ${{ github.event.inputs.e2e_tests_infra_flavor || 'stagemole' }}
-
 jobs:
   prepare:
     name: Prepare
@@ -79,8 +76,18 @@ jobs:
         if: ${{ github.event.inputs.override_container_image == '' }}
         run: |
           echo "inner_container_image=$(cat ./building/android-container-image.txt)" >> $GITHUB_ENV
+
+      # Preparing variables this way instead of using `env.*` due to:
+      # https://github.com/orgs/community/discussions/26388
+      - name: Prepare environment variables
+        run: |
+          echo "INNER_E2E_TEST_INFRA_FLAVOR=${{ github.event.inputs.e2e_tests_infra_flavor || 'stagemole' }}" \
+          >> $GITHUB_ENV
+          echo "INNER_E2E_TEST_REPEAT=${{ github.event.inputs.e2e_test_repeat || 0 }}" >> $GITHUB_ENV
     outputs:
       container_image: ${{ env.inner_container_image }}
+      E2E_TEST_INFRA_FLAVOR: ${{ env.INNER_E2E_TEST_INFRA_FLAVOR }}
+      E2E_TEST_REPEAT: ${{ env.INNER_E2E_TEST_REPEAT }}
 
   build-native:
     name: Build native # Used by wait for jobs.
@@ -113,7 +120,7 @@ jobs:
       - name: Checkout wireguard-go-rs recursively
         run: |
           git config --global --add safe.directory '*'
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Calculate native lib cache hash
         id: native-lib-cache-hash
@@ -272,7 +279,8 @@ jobs:
       - name: Build stagemole app
         uses: burrunan/gradle-cache-action@v1
         if: >
-          (github.event.inputs.e2e_test_repeat != '0' && env.E2E_TEST_INFRA_FLAVOR == 'stagemole') ||
+          (needs.prepare.outputs.E2E_TEST_REPEAT != '0' &&
+          needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole') ||
           github.event.inputs.run_firebase_tests == 'true'
         with:
           job-id: jdk17
@@ -367,7 +375,7 @@ jobs:
             test-repeat: 1
           - test-type: mockapi
             path: android/test/mockapi/build/outputs/apk
-            test-repeat: ${{ github.event_name == 'schedule' && 10 || github.event.inputs.mockapi_test_repeat || 1 }}
+            test-repeat: ${{ github.event_name == 'schedule' && 100 || github.event.inputs.mockapi_test_repeat || 1 }}
     steps:
       - name: Prepare report dir
         if: ${{ matrix.test-repeat != 0 }}
@@ -425,16 +433,26 @@ jobs:
   instrumented-e2e-tests:
     name: Run instrumented e2e tests
     # Temporary workaround for targeting the runner android-runner-v1
-    runs-on: [self-hosted, android-device, android-emulator]
+    runs-on: [self-hosted, android-device]
+    needs: [prepare, build-app, build-instrumented-tests]
     if: >
       github.event_name == 'schedule' ||
-      (github.event.inputs.e2e_test_repeat != '0' && github.event_name != 'pull_request')
-    needs: [build-app, build-instrumented-tests]
+      (github.event_name != 'pull_request' && needs.prepare.outputs.E2E_TEST_REPEAT != '0')
     strategy:
       matrix:
         include:
-          - test-repeat: ${{ github.event.inputs.e2e_test_repeat || 1 }}
+          - test-repeat: ${{ needs.prepare.outputs.E2E_TEST_REPEAT || 1 }}
     steps:
+      - name: Resolve unique runner test account secret name
+        if: needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod'
+        run: |
+          echo "RUNNER_SECRET_NAME=ANDROID_PROD_TEST_ACCOUNT_$(echo $RUNNER_NAME | tr '[:lower:]-' '[:upper:]_')" \
+          >> $GITHUB_ENV
+
+      - name: Resolve runner test account
+        if: needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod'
+        run: echo "RESOLVED_TEST_ACCOUNT=${{ secrets[env.RUNNER_SECRET_NAME] }}" >> $GITHUB_ENV
+
       - name: Prepare report dir
         if: ${{ matrix.test-repeat != 0 }}
         id: prepare-report-dir
@@ -474,12 +492,11 @@ jobs:
         env:
           AUTO_FETCH_TEST_HELPER_APKS: true
           TEST_TYPE: e2e
-          BILLING_FLAVOR: ${{ env.E2E_TEST_INFRA_FLAVOR == 'prod' && 'oss' || 'play' }}
-          INFRA_FLAVOR: ${{ env.E2E_TEST_INFRA_FLAVOR }}
+          BILLING_FLAVOR: ${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'prod' && 'oss' || 'play' }}
+          INFRA_FLAVOR: "${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR }}"
           PARTNER_AUTH: |-
-            ${{ env.E2E_TEST_INFRA_FLAVOR == 'stagemole' && secrets.STAGEMOLE_PARTNER_AUTH || '' }}
-          VALID_TEST_ACCOUNT_NUMBER: |-
-            ${{ env.E2E_TEST_INFRA_FLAVOR == 'prod' && secrets.ANDROID_PROD_TEST_ACCOUNT || '' }}
+            ${{ needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole' && secrets.STAGEMOLE_PARTNER_AUTH || '' }}
+          VALID_TEST_ACCOUNT_NUMBER: ${{ env.RESOLVED_TEST_ACCOUNT }}
           INVALID_TEST_ACCOUNT_NUMBER: '0000000000000000'
           ENABLE_HIGHLY_RATE_LIMITED_TESTS: ${{ github.event_name == 'schedule' && 'true' || 'false' }}
           ENABLE_ACCESS_TO_LOCAL_API_TESTS: true
@@ -489,7 +506,7 @@ jobs:
       - name: Upload e2e instrumentation report
         uses: actions/upload-artifact@v4
         if: >
-          always() && matrix.test-repeat != 0 && env.E2E_TEST_INFRA_FLAVOR == 'stagemole'
+          always() && matrix.test-repeat != 0 && needs.prepare.outputs.E2E_TEST_INFRA_FLAVOR == 'stagemole'
         with:
           name: e2e-instrumentation-report
           path: ${{ steps.prepare-report-dir.outputs.report_dir }}
diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml
index 218eeeef23d5..cacb0236cbd7 100644
--- a/.github/workflows/clippy.yml
+++ b/.github/workflows/clippy.yml
@@ -48,7 +48,7 @@ jobs:
       - name: Checkout submodules
         run: |
           git submodule update --init --depth=1 dist-assets/binaries
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install build dependencies
         if: matrix.os == 'ubuntu-latest'
@@ -99,7 +99,7 @@ jobs:
       - name: Checkout wireguard-go submodule
         run: |
           git config --global --add safe.directory '*'
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Clippy check
         env:
diff --git a/.github/workflows/daemon.yml b/.github/workflows/daemon.yml
index 8e22397b9cfc..7bf893d1ea0e 100644
--- a/.github/workflows/daemon.yml
+++ b/.github/workflows/daemon.yml
@@ -77,7 +77,7 @@ jobs:
         run: |
           git config --global --add safe.directory '*'
           git submodule update --init --depth=1 dist-assets/binaries
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       # The container image already has rustup and the pinned version of Rust
       - name: Install Rust toolchain
@@ -100,7 +100,7 @@ jobs:
       - name: Checkout wireguard-go submodule
         run: |
           git config --global --add safe.directory '*'
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install Protoc
         uses: arduino/setup-protoc@v3
@@ -131,7 +131,7 @@ jobs:
       - name: Checkout submodules
         run: |
           git submodule update --init --depth=1
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install Protoc
         # NOTE: ARM runner already has protoc
diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml
index a44905e55f78..2165308d766f 100644
--- a/.github/workflows/desktop-e2e.yml
+++ b/.github/workflows/desktop-e2e.yml
@@ -124,7 +124,7 @@ jobs:
         run: |
           git config --global --add safe.directory '*'
           git submodule update --init --depth=1 dist-assets/binaries
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
       - name: Build app
         env:
           USE_MOLD: false
@@ -187,7 +187,7 @@ jobs:
           submodules: true
       - name: Checkout submodules
         run: |
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
       - name: Install Protoc
         uses: arduino/setup-protoc@v3
         with:
@@ -274,7 +274,7 @@ jobs:
       - name: Checkout submodules
         run: |
           git config --global --add safe.directory '*'
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
       - name: Install Go
         uses: actions/setup-go@v3
         with:
diff --git a/.github/workflows/ios-rust-ffi.yml b/.github/workflows/ios-rust-ffi.yml
index 6d1457d4f1a6..bc289a03f952 100644
--- a/.github/workflows/ios-rust-ffi.yml
+++ b/.github/workflows/ios-rust-ffi.yml
@@ -20,6 +20,11 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v4
 
+      - name: Checkout wireguard-go-rs
+        run: |
+          git config --global --add safe.directory '*'
+          git submodule update --init wireguard-go-rs
+
       - name: Install Protoc
         uses: arduino/setup-protoc@v3
         with:
diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml
index c65bf1450416..084896fda431 100644
--- a/.github/workflows/osv-scanner-pr.yml
+++ b/.github/workflows/osv-scanner-pr.yml
@@ -17,4 +17,6 @@ jobs:
       actions: read
 
     # yamllint disable rule:line-length
-    uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@19ec1116569a47416e11a45848722b1af31a857b"  # v1.9.0
+    uses: "mullvad/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@ab8175fc65a74d8c0308f623b1c617a39bdc34fe"  # v1.9.0
+    with:
+      checkout-submodules: true
diff --git a/.github/workflows/osv-scanner-scheduled.yml b/.github/workflows/osv-scanner-scheduled.yml
index 58b982a107d2..a42305db844a 100644
--- a/.github/workflows/osv-scanner-scheduled.yml
+++ b/.github/workflows/osv-scanner-scheduled.yml
@@ -18,4 +18,6 @@ jobs:
       actions: read
 
     # yamllint disable rule:line-length
-    uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@19ec1116569a47416e11a45848722b1af31a857b"  # v1.9.0
+    uses: "mullvad/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@ab8175fc65a74d8c0308f623b1c617a39bdc34fe"  # v1.9.0
+    with:
+      checkout-submodules: true
diff --git a/.github/workflows/rust-unused-dependencies.yml b/.github/workflows/rust-unused-dependencies.yml
index aed6d4ea2202..47c58bcca27b 100644
--- a/.github/workflows/rust-unused-dependencies.yml
+++ b/.github/workflows/rust-unused-dependencies.yml
@@ -48,7 +48,7 @@ jobs:
         run: |
           git config --global --add safe.directory '*'
           git submodule update --init --depth=1 dist-assets/binaries
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install nightly Rust toolchain
         run: rustup override set $RUST_NIGHTLY_TOOLCHAIN
@@ -79,7 +79,7 @@ jobs:
       - name: Checkout wireguard-go submodule
         run: |
           git config --global --add safe.directory '*'
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install nightly Rust
         run: |
@@ -106,7 +106,7 @@ jobs:
         run: |
           git config --global --add safe.directory '*'
           git submodule update --init --depth=1
-          git submodule update --init --recursive --depth=1 wireguard-go-rs
+          git submodule update --init wireguard-go-rs/libwg/wireguard-go
 
       - name: Install msbuild
         if: matrix.os == 'windows-latest'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73f8ada84f3d..868d7a072e05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,12 @@ Line wrap the file at 100 chars.                                              Th
 - Add back wireguard-go (userspace WireGuard) support.
 
 
+## [2025.3] - 2025-02-07
+### Changed
+- Change order of items in settings view to show DAITA and multihop at the top.
+- Update Electron from 33.2.1 to 33.4.0.
+
+
 ## [2025.3-beta1] - 2025-01-21
 ### Added
 #### Windows
diff --git a/Cargo.lock b/Cargo.lock
index e7a426717dff..d280ba649b82 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -431,9 +431,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
 [[package]]
 name = "bytes"
-version = "1.7.1"
+version = "1.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
+checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
 
 [[package]]
 name = "c2rust-bitfields"
@@ -1248,9 +1248,9 @@ dependencies = [
 
 [[package]]
 name = "futures"
-version = "0.3.30"
+version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -1279,9 +1279,9 @@ checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.30"
+version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -2107,9 +2107,9 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
 
 [[package]]
 name = "jnix"
-version = "0.5.1"
+version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fd797d41e48568eb956ded20d7e5e3f2df1c02980d9e5b9aab9b47bd3a9f599"
+checksum = "542b2072131a62ec940ee161ff0a01e7a1c2a129796b30143efc952cb6e0f28f"
 dependencies = [
  "jni",
  "jnix-macros",
@@ -2196,9 +2196,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.158"
+version = "0.2.169"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
+checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
 
 [[package]]
 name = "libdbus-sys"
@@ -2888,9 +2888,9 @@ dependencies = [
 
 [[package]]
 name = "netlink-sys"
-version = "0.8.6"
+version = "0.8.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "416060d346fbaf1f23f9512963e3e878f1a78e707cb699ba9215761754244307"
+checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
 dependencies = [
  "bytes",
  "futures",
@@ -4789,6 +4789,7 @@ dependencies = [
  "bitflags 2.6.0",
  "futures",
  "ipnetwork",
+ "jnix",
  "libc",
  "log",
  "netlink-packet-route",
diff --git a/README.md b/README.md
index aeec4cb332cf..46d5cf3c6343 100644
--- a/README.md
+++ b/README.md
@@ -92,9 +92,9 @@ cd mullvadvpn-app
 git submodule update --init
 ```
 
-On Android, Linux and macOS you also want to checkout the wireguard-go submodule recursively:
+On Android, Windows, Linux and macOS you also want to checkout the wireguard-go submodule:
 ```bash
-git submodule update --init --recursive --depth=1 wireguard-go-rs
+git submodule update --init wireguard-go-rs/libwg/wireguard-go
 ```
 Further details on why this is necessary can be found in the [wireguard-go-rs crate](./wireguard-go-rs/README.md).
 
diff --git a/android/BuildInstructions.md b/android/BuildInstructions.md
index cfff1b3d73af..5755737df84b 100644
--- a/android/BuildInstructions.md
+++ b/android/BuildInstructions.md
@@ -128,8 +128,6 @@ Linux distro:
 #### 5. Install and configure Rust toolchain
 
 - Get the latest **stable** Rust toolchain via [rustup.rs](https://rustup.rs/).
-  Also install `cbindgen` which is required to build `wireguard-go-rs`:
-  `cargo install --force cbindgen`
 
 - Configure Android cross-compilation targets and set up linker and archiver. This can be done by setting the following
 environment variables:
@@ -157,7 +155,7 @@ environment variables:
   ```
 
 #### 6. Download wireguard-go-rs submodule
-Run the following command to download wireguard-go-rs submodule: `git submodule update --init --recursive --depth=1 wireguard-go-rs`
+Run the following command to download wireguard-go-rs submodule: `git submodule update --init wireguard-go-rs/libwg/wireguard-go`
 
 ### Debug build
 Run the following command to build a debug build:
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md
index f03a1617972b..05f3443af5fc 100644
--- a/android/CHANGELOG.md
+++ b/android/CHANGELOG.md
@@ -22,9 +22,17 @@ Line wrap the file at 100 chars.                                              Th
 * **Security**: in case of vulnerabilities.
 
 ## [Unreleased]
+### Changed
+- Disable Wireguard port setting when a obfuscation is selected since it is not used when an 
+  obfuscation is applied.
+
 ### Removed
 - Remove Google's resolvers from encrypted DNS proxy.
 
+### Fixed
+- Will no longer try to connect over IPv6 if IPv6 is not available.
+
+
 ## [android/2024.10-beta2] - 2024-12-20
 
 ### Fixed
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
index 3a7df2b84161..bbe22c9b1110 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt
@@ -190,7 +190,7 @@ private fun BaseButton(
             Text(
                 text = text,
                 textAlign = TextAlign.Center,
-                style = MaterialTheme.typography.bodyMedium,
+                style = MaterialTheme.typography.titleMedium,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis,
                 modifier = Modifier.weight(1f),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
index f67e7228af43..18ad113a2612 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadSegmentedButton.kt
@@ -54,7 +54,7 @@ private fun SingleChoiceSegmentedButtonRowScope.MullvadSegmentedButton(
             Text(
                 text = text,
                 textAlign = TextAlign.Center,
-                style = MaterialTheme.typography.bodyMedium,
+                style = MaterialTheme.typography.titleMedium,
                 maxLines = 1,
                 overflow = TextOverflow.Ellipsis,
             )
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
index 17c4d24460ab..8e22be8b7e00 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt
@@ -148,7 +148,7 @@ fun BaseSubtitleCell(
                     start = Dimens.cellStartPadding,
                     top = Dimens.cellFooterTopPadding,
                     end = Dimens.cellEndPadding,
-                    bottom = Dimens.cellLabelVerticalPadding,
+                    bottom = Dimens.cellVerticalSpacing,
                 )
                 .fillMaxWidth()
                 .wrapContentHeight(),
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
index 0ea351e8b14a..bc6aebe5d0ef 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomPortCell.kt
@@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
@@ -30,7 +27,7 @@ import net.mullvad.mullvadvpn.compose.component.SpacedColumn
 import net.mullvad.mullvadvpn.lib.model.Port
 import net.mullvad.mullvadvpn.lib.theme.AppTheme
 import net.mullvad.mullvadvpn.lib.theme.Dimens
-import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
+import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled
 import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
 import net.mullvad.mullvadvpn.lib.theme.color.onSelected
 import net.mullvad.mullvadvpn.lib.theme.color.selected
@@ -65,6 +62,7 @@ fun CustomPortCell(
     port: Port?,
     mainTestTag: String = "",
     numberTestTag: String = "",
+    isEnabled: Boolean = true,
     onMainCellClicked: () -> Unit,
     onPortCellClicked: () -> Unit,
 ) {
@@ -77,7 +75,7 @@ fun CustomPortCell(
             verticalAlignment = Alignment.CenterVertically,
             horizontalArrangement = Arrangement.Start,
             modifier =
-                Modifier.clickable { onMainCellClicked() }
+                Modifier.clickable(enabled = isEnabled) { onMainCellClicked() }
                     .height(Dimens.cellHeight)
                     .weight(1f)
                     .background(
@@ -90,13 +88,10 @@ fun CustomPortCell(
                     .padding(start = Dimens.cellStartPadding)
                     .testTag(mainTestTag),
         ) {
-            Icon(
-                imageVector = Icons.Default.Check,
-                contentDescription = null,
-                tint = MaterialTheme.colorScheme.onSelected,
-                modifier =
-                    Modifier.padding(end = Dimens.selectableCellTextMargin)
-                        .alpha(if (isSelected) AlphaVisible else AlphaInvisible),
+            SelectableIcon(
+                isSelected = isSelected,
+                iconContentDescription = null,
+                isEnabled = isEnabled,
             )
             BaseCellTitle(
                 title = title,
@@ -104,16 +99,17 @@ fun CustomPortCell(
                 textAlign = TextAlign.Start,
                 textColor =
                     if (isSelected) {
-                        MaterialTheme.colorScheme.onSelected
-                    } else {
-                        MaterialTheme.colorScheme.onSurface
-                    },
+                            MaterialTheme.colorScheme.onSelected
+                        } else {
+                            MaterialTheme.colorScheme.onSurface
+                        }
+                        .copy(alpha = if (isEnabled) AlphaVisible else AlphaDisabled),
             )
         }
         Spacer(modifier = Modifier.width(Dimens.verticalSpacer))
         Box(
             modifier =
-                Modifier.clickable { onPortCellClicked() }
+                Modifier.clickable(enabled = isEnabled) { onPortCellClicked() }
                     .height(Dimens.cellHeight)
                     .wrapContentWidth()
                     .defaultMinSize(minWidth = Dimens.customPortBoxMinWidth)
@@ -122,7 +118,15 @@ fun CustomPortCell(
         ) {
             Text(
                 text = port?.value?.toString() ?: stringResource(id = R.string.port),
-                color = MaterialTheme.colorScheme.onPrimary,
+                color =
+                    MaterialTheme.colorScheme.onPrimary.copy(
+                        alpha =
+                            if (isEnabled) {
+                                AlphaVisible
+                            } else {
+                                AlphaDisabled
+                            }
+                    ),
                 modifier = Modifier.align(Alignment.Center),
             )
         }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
index ece54c9102ab..b05b0f2cbe73 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt
@@ -136,7 +136,7 @@ internal fun NavigationCellBody(
         verticalAlignment = Alignment.CenterVertically,
         modifier = modifier.wrapContentWidth().wrapContentHeight(),
     ) {
-        Text(text = content, style = MaterialTheme.typography.bodyMedium, color = textColor)
+        Text(text = content, style = MaterialTheme.typography.titleMedium, color = textColor)
         Spacer(modifier = Modifier.width(Dimens.sideMargin))
         if (isExternalLink) {
             DefaultExternalLinkView(content, tint = contentColor)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
index 0de30c408a11..779b4792b423 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt
@@ -320,7 +320,6 @@ fun VpnSettingsScreen(
         ) {
             if (state.systemVpnSettingsAvailable) {
                 item {
-                    Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
                     NavigationComposeCell(
                         title = stringResource(id = R.string.auto_connect_and_lockdown_mode),
                         onClick = { navigateToAutoConnectScreen() },
@@ -333,7 +332,6 @@ fun VpnSettingsScreen(
                 }
             } else {
                 item {
-                    Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
                     HeaderSwitchComposeCell(
                         title = stringResource(R.string.connect_on_start),
                         isToggled = state.autoStartAndConnectOnBoot,
@@ -350,7 +348,6 @@ fun VpnSettingsScreen(
             }
 
             item {
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
                 HeaderSwitchComposeCell(
                     title = stringResource(R.string.local_network_sharing),
                     isToggled = state.isLocalNetworkSharingEnabled,
@@ -358,7 +355,7 @@ fun VpnSettingsScreen(
                     onCellClicked = { newValue -> onToggleLocalNetworkSharing(newValue) },
                     onInfoClicked = navigateToLocalNetworkSharingInfo,
                 )
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
+                Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
             }
 
             itemWithDivider {
@@ -443,7 +440,7 @@ fun VpnSettingsScreen(
                                     start = Dimens.cellStartPadding,
                                     top = topPadding,
                                     end = Dimens.cellEndPadding,
-                                    bottom = Dimens.cellLabelVerticalPadding,
+                                    bottom = Dimens.cellVerticalSpacing,
                                 )
                         )
                     }
@@ -497,7 +494,7 @@ fun VpnSettingsScreen(
                             start = Dimens.cellStartPadding,
                             top = topPadding,
                             end = Dimens.cellEndPadding,
-                            bottom = Dimens.cellLabelVerticalPadding,
+                            bottom = Dimens.cellVerticalSpacing,
                         ),
                 )
             }
@@ -507,6 +504,7 @@ fun VpnSettingsScreen(
                     title = stringResource(id = R.string.wireguard_port_title),
                     onInfoClicked = { navigateToWireguardPortInfo(state.availablePortRanges) },
                     onCellClicked = { navigateToWireguardPortInfo(state.availablePortRanges) },
+                    isEnabled = state.isWireguardPortEnabled,
                 )
             }
 
@@ -515,6 +513,7 @@ fun VpnSettingsScreen(
                     title = stringResource(id = R.string.automatic),
                     isSelected = state.selectedWireguardPort == Constraint.Any,
                     onCellClicked = { onWireguardPortSelected(Constraint.Any) },
+                    isEnabled = state.isWireguardPortEnabled,
                 )
             }
 
@@ -530,6 +529,7 @@ fun VpnSettingsScreen(
                             ),
                         isSelected = state.selectedWireguardPort.getOrNull() == port,
                         onCellClicked = { onWireguardPortSelected(Constraint.Only(port)) },
+                        isEnabled = state.isWireguardPortEnabled,
                     )
                 }
             }
@@ -547,13 +547,34 @@ fun VpnSettingsScreen(
                         }
                     },
                     onPortCellClicked = navigateToWireguardPortDialog,
+                    isEnabled = state.isWireguardPortEnabled,
                     mainTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG,
                     numberTestTag = LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG,
                 )
             }
 
+            if (!state.isWireguardPortEnabled) {
+                item {
+                    Text(
+                        text =
+                            stringResource(
+                                id = R.string.wg_port_subtitle,
+                                stringResource(R.string.wireguard),
+                            ),
+                        style = MaterialTheme.typography.labelMedium,
+                        color = MaterialTheme.colorScheme.onSurfaceVariant,
+                        modifier =
+                            Modifier.padding(
+                                start = Dimens.cellStartPadding,
+                                top = topPadding,
+                                end = Dimens.cellEndPadding,
+                            ),
+                    )
+                }
+            }
+
             itemWithDivider {
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
+                Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
                 InformationComposeCell(
                     title = stringResource(R.string.obfuscation_title),
                     onInfoClicked = navigateToObfuscationInfo,
@@ -598,7 +619,7 @@ fun VpnSettingsScreen(
             }
 
             itemWithDivider {
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
+                Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
                 InformationComposeCell(
                     title = stringResource(R.string.quantum_resistant_title),
                     onInfoClicked = navigateToQuantumResistanceInfo,
@@ -627,16 +648,13 @@ fun VpnSettingsScreen(
                     isSelected = state.quantumResistant == QuantumResistantState.Off,
                     onCellClicked = { onSelectQuantumResistanceSetting(QuantumResistantState.Off) },
                 )
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
+                Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing))
             }
 
             item {
                 MtuComposeCell(mtuValue = state.mtu, onEditMtu = { navigateToMtuDialog(state.mtu) })
             }
-            item {
-                MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG))
-                Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding))
-            }
+            item { MtuSubtitle(modifier = Modifier.testTag(LAZY_LIST_LAST_ITEM_TEST_TAG)) }
 
             item { ServerIpOverrides(navigateToServerIpOverrides) }
         }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
index 49d0ebd4aa34..c9fd0257c028 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VpnSettingsUiState.kt
@@ -29,6 +29,9 @@ data class VpnSettingsUiState(
         selectedWireguardPort is Constraint.Only &&
             selectedWireguardPort.value == customWireguardPort
 
+    val isWireguardPortEnabled =
+        obfuscationMode == ObfuscationMode.Auto || obfuscationMode == ObfuscationMode.Off
+
     companion object {
         fun createDefault(
             mtu: Mtu? = null,
diff --git a/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt b/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt
new file mode 100644
index 000000000000..27e7658a11de
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/talpid/TalpidVpnServiceFallbackDnsTest.kt
@@ -0,0 +1,146 @@
+package net.mullvad.talpid
+
+import android.net.VpnService
+import android.os.ParcelFileDescriptor
+import arrow.core.right
+import io.mockk.MockKAnnotations
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import java.net.InetAddress
+import net.mullvad.mullvadvpn.lib.common.test.assertLists
+import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe
+import net.mullvad.mullvadvpn.lib.model.Prepared
+import net.mullvad.talpid.model.CreateTunResult
+import net.mullvad.talpid.model.InetNetwork
+import net.mullvad.talpid.model.TunConfig
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertInstanceOf
+
+class TalpidVpnServiceFallbackDnsTest {
+    lateinit var talpidVpnService: TalpidVpnService
+    var builderMockk = mockk<VpnService.Builder>()
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        mockkStatic(VPN_SERVICE_EXTENSION)
+
+        talpidVpnService = spyk<TalpidVpnService>(recordPrivateCalls = true)
+        every { talpidVpnService.prepareVpnSafe() } returns Prepared.right()
+        builderMockk = mockk<VpnService.Builder>()
+
+        mockkConstructor(VpnService.Builder::class)
+        every { anyConstructed<VpnService.Builder>().setMtu(any()) } returns builderMockk
+        every { anyConstructed<VpnService.Builder>().setBlocking(any()) } returns builderMockk
+        every { anyConstructed<VpnService.Builder>().addAddress(any<InetAddress>(), any()) } returns
+            builderMockk
+        every { anyConstructed<VpnService.Builder>().addRoute(any<InetAddress>(), any()) } returns
+            builderMockk
+        every {
+            anyConstructed<VpnService.Builder>()
+                .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER)
+        } returns builderMockk
+        val parcelFileDescriptor: ParcelFileDescriptor = mockk()
+        every { anyConstructed<VpnService.Builder>().establish() } returns parcelFileDescriptor
+        every { parcelFileDescriptor.detachFd() } returns 1
+    }
+
+    @Test
+    fun `opening tun with no DnsServers should add fallback DNS server`() {
+        val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf())
+
+        val result = talpidVpnService.openTun(tunConfig)
+
+        assertInstanceOf<CreateTunResult.Success>(result)
+
+        // Fallback DNS server should be added if no DNS servers are provided
+        coVerify(exactly = 1) {
+            anyConstructed<VpnService.Builder>()
+                .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER)
+        }
+    }
+
+    @Test
+    fun `opening tun with all bad DnsServers should return InvalidDnsServers and add fallback`() {
+        val badDns1 = InetAddress.getByName("0.0.0.0")
+        val badDns2 = InetAddress.getByName("255.255.255.255")
+        every { anyConstructed<VpnService.Builder>().addDnsServer(badDns1) } throws
+            IllegalArgumentException()
+        every { anyConstructed<VpnService.Builder>().addDnsServer(badDns2) } throws
+            IllegalArgumentException()
+
+        val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(badDns1, badDns2))
+        val result = talpidVpnService.openTun(tunConfig)
+
+        assertInstanceOf<CreateTunResult.InvalidDnsServers>(result)
+        assertLists(tunConfig.dnsServers, result.addresses)
+        // Fallback DNS server should be added if no valid DNS servers are provided
+        coVerify(exactly = 1) {
+            anyConstructed<VpnService.Builder>()
+                .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER)
+        }
+    }
+
+    @Test
+    fun `opening tun with 1 good and 1 bad DnsServers should return InvalidDnsServers`() {
+        val goodDnsServer = InetAddress.getByName("1.1.1.1")
+        val badDns = InetAddress.getByName("255.255.255.255")
+        every { anyConstructed<VpnService.Builder>().addDnsServer(goodDnsServer) } returns
+            builderMockk
+        every { anyConstructed<VpnService.Builder>().addDnsServer(badDns) } throws
+            IllegalArgumentException()
+
+        val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(goodDnsServer, badDns))
+        val result = talpidVpnService.openTun(tunConfig)
+
+        assertInstanceOf<CreateTunResult.InvalidDnsServers>(result)
+        assertLists(arrayListOf(badDns), result.addresses)
+
+        // Fallback DNS server should not be added since we have 1 good DNS server
+        coVerify(exactly = 0) {
+            anyConstructed<VpnService.Builder>()
+                .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER)
+        }
+    }
+
+    @Test
+    fun `providing good dns servers should not add the fallback dns and return success`() {
+        val goodDnsServer = InetAddress.getByName("1.1.1.1")
+        every { anyConstructed<VpnService.Builder>().addDnsServer(goodDnsServer) } returns
+            builderMockk
+
+        val tunConfig = baseTunConfig.copy(dnsServers = arrayListOf(goodDnsServer))
+        val result = talpidVpnService.openTun(tunConfig)
+
+        assertInstanceOf<CreateTunResult.Success>(result)
+
+        // Fallback DNS server should not be added since we have good DNS servers.
+        coVerify(exactly = 0) {
+            anyConstructed<VpnService.Builder>()
+                .addDnsServer(TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER)
+        }
+    }
+
+    companion object {
+        private const val VPN_SERVICE_EXTENSION =
+            "net.mullvad.mullvadvpn.lib.common.util.VpnServiceUtilsKt"
+
+        val baseTunConfig =
+            TunConfig(
+                addresses = arrayListOf(InetAddress.getByName("45.83.223.209")),
+                dnsServers = arrayListOf(),
+                routes =
+                    arrayListOf(
+                        InetNetwork(InetAddress.getByName("0.0.0.0"), 0),
+                        InetNetwork(InetAddress.getByName("::"), 0),
+                    ),
+                mtu = 1280,
+                excludedPackages = arrayListOf(),
+            )
+    }
+}
diff --git a/android/docker/Dockerfile b/android/docker/Dockerfile
index 3635fc55c0e7..705c0d1ed044 100644
--- a/android/docker/Dockerfile
+++ b/android/docker/Dockerfile
@@ -117,11 +117,4 @@ RUN patch -p1 -f -N -r- -d /usr/local/go < /tmp/goruntime-boottime-over-monotoni
 # Add rust targets
 RUN rustup target add x86_64-linux-android i686-linux-android aarch64-linux-android armv7-linux-androideabi
 
-# Install cbindgen to address maybenot.h (checked in) sometimes needing to be
-# re-generated due to how `make` looks at last-modifications while git neither
-# stores nor consistently sets modification metadata on file checkout.
-# This is an intermediate solution that will be further improved as part of
-# issue: DROID-1328.
-RUN cargo install --force cbindgen --version "0.26.0" && rm -rf ~/.cargo/registry
-
 WORKDIR /build
diff --git a/android/docs/BuildInstructions.macos.md b/android/docs/BuildInstructions.macos.md
index 0368c1e917a7..4ef004d595e4 100644
--- a/android/docs/BuildInstructions.macos.md
+++ b/android/docs/BuildInstructions.macos.md
@@ -28,11 +28,6 @@ Finish the install of `rustup`:
 rustup-init
 ```
 
-Install `cbindgen` which is required to build `wireguard-go-rs`:
-```bash
-cargo install --force cbindgen
-```
-
 ## 2. Install SDK Tools and Android NDK Toolchain
 Open Android Studio -> Tools -> SDK Manager, and install `Android SDK Command-line Tools (latest)`.
 
@@ -70,7 +65,7 @@ export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$NDK_TOOLCHAIN_DIR/x86_64-linux
 wireguard-go-rs submodule need to be downloaded:
 
 ```bash
-git submodule update --init --recursive --depth=1 wireguard-go-rs
+git submodule update --init wireguard-go-rs/libwg/wireguard-go
 ```
 
 ## 4. Debug build
diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt
index 59833cb3961d..06c862936b65 100644
--- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt
+++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/VpnServiceUtils.kt
@@ -2,10 +2,14 @@ package net.mullvad.mullvadvpn.lib.common.util
 
 import android.content.Context
 import android.content.Intent
+import android.net.VpnService
 import android.net.VpnService.prepare
+import android.os.ParcelFileDescriptor
 import arrow.core.Either
-import arrow.core.flatten
+import arrow.core.flatMap
 import arrow.core.left
+import arrow.core.raise.either
+import arrow.core.raise.ensureNotNull
 import arrow.core.right
 import co.touchlab.kermit.Logger
 import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.getInstalledPackagesList
@@ -13,6 +17,8 @@ import net.mullvad.mullvadvpn.lib.model.PrepareError
 import net.mullvad.mullvadvpn.lib.model.Prepared
 
 /**
+ * Prepare to establish a VPN connection safely.
+ *
  * Invoking VpnService.prepare() can result in 3 out comes:
  * 1. IllegalStateException - There is a legacy VPN profile marked as always on
  * 2. Intent
@@ -34,7 +40,7 @@ fun Context.prepareVpnSafe(): Either<PrepareError, Prepared> =
                 else -> throw it
             }
         }
-        .map { intent ->
+        .flatMap { intent ->
             if (intent == null) {
                 Prepared.right()
             } else {
@@ -46,7 +52,6 @@ fun Context.prepareVpnSafe(): Either<PrepareError, Prepared> =
                 }
             }
         }
-        .flatten()
 
 fun Context.getAlwaysOnVpnAppName(): String? {
     return resolveAlwaysOnVpnPackageName()
@@ -59,3 +64,38 @@ fun Context.getAlwaysOnVpnAppName(): String? {
         ?.loadLabel(packageManager)
         ?.toString()
 }
+
+/**
+ * Establish a VPN connection safely.
+ *
+ * This function wraps the [VpnService.Builder.establish] function and catches any exceptions that
+ * may be thrown and type them to a more specific error.
+ *
+ * @return [ParcelFileDescriptor] if successful, [EstablishError] otherwise
+ */
+fun VpnService.Builder.establishSafe(): Either<EstablishError, ParcelFileDescriptor> = either {
+    val vpnInterfaceFd =
+        Either.catch { establish() }
+            .mapLeft {
+                when (it) {
+                    is IllegalStateException -> EstablishError.ParameterNotApplied(it)
+                    is IllegalArgumentException -> EstablishError.ParameterNotAccepted(it)
+                    else -> EstablishError.UnknownError(it)
+                }
+            }
+            .bind()
+
+    ensureNotNull(vpnInterfaceFd) { EstablishError.NullVpnInterface }
+
+    vpnInterfaceFd
+}
+
+sealed interface EstablishError {
+    data class ParameterNotApplied(val exception: IllegalStateException) : EstablishError
+
+    data class ParameterNotAccepted(val exception: IllegalArgumentException) : EstablishError
+
+    data object NullVpnInterface : EstablishError
+
+    data class UnknownError(val error: Throwable) : EstablishError
+}
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
index daa04fc8d996..fe4cf11881ba 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
@@ -36,9 +36,6 @@ import net.mullvad.mullvadvpn.lib.model.DnsState
 import net.mullvad.mullvadvpn.lib.model.Endpoint
 import net.mullvad.mullvadvpn.lib.model.ErrorState
 import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
-import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.AuthFailed
-import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.OtherAlwaysOnApp
-import net.mullvad.mullvadvpn.lib.model.ErrorStateCause.TunnelParameterError
 import net.mullvad.mullvadvpn.lib.model.FeatureIndicator
 import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
 import net.mullvad.mullvadvpn.lib.model.GeoLocationId
@@ -125,7 +122,7 @@ private fun ManagementInterface.TunnelState.Error.toDomain(): TunnelState.Error
     val otherAlwaysOnAppError =
         errorState.let {
             if (it.hasOtherAlwaysOnAppError()) {
-                OtherAlwaysOnApp(it.otherAlwaysOnAppError.appName)
+                ErrorStateCause.OtherAlwaysOnApp(it.otherAlwaysOnAppError.appName)
             } else {
                 null
             }
@@ -238,7 +235,7 @@ internal fun ManagementInterface.ErrorState.toDomain(
         cause =
             when (cause!!) {
                 ManagementInterface.ErrorState.Cause.AUTH_FAILED ->
-                    AuthFailed(authFailedError.toDomain())
+                    ErrorStateCause.AuthFailed(authFailedError.toDomain())
                 ManagementInterface.ErrorState.Cause.IPV6_UNAVAILABLE ->
                     ErrorStateCause.Ipv6Unavailable
                 ManagementInterface.ErrorState.Cause.SET_FIREWALL_POLICY_ERROR ->
@@ -247,7 +244,7 @@ internal fun ManagementInterface.ErrorState.toDomain(
                 ManagementInterface.ErrorState.Cause.START_TUNNEL_ERROR ->
                     ErrorStateCause.StartTunnelError
                 ManagementInterface.ErrorState.Cause.TUNNEL_PARAMETER_ERROR ->
-                    TunnelParameterError(parameterError.toDomain())
+                    ErrorStateCause.TunnelParameterError(parameterError.toDomain())
                 ManagementInterface.ErrorState.Cause.IS_OFFLINE -> ErrorStateCause.IsOffline
                 ManagementInterface.ErrorState.Cause.SPLIT_TUNNEL_ERROR ->
                     ErrorStateCause.StartTunnelError
@@ -255,7 +252,6 @@ internal fun ManagementInterface.ErrorState.toDomain(
                 ManagementInterface.ErrorState.Cause.NEED_FULL_DISK_PERMISSIONS,
                 ManagementInterface.ErrorState.Cause.CREATE_TUNNEL_DEVICE ->
                     throw IllegalArgumentException("Unrecognized error state cause")
-
                 ManagementInterface.ErrorState.Cause.NOT_PREPARED -> ErrorStateCause.NotPrepared
                 ManagementInterface.ErrorState.Cause.OTHER_ALWAYS_ON_APP -> otherAlwaysOnApp!!
                 ManagementInterface.ErrorState.Cause.OTHER_LEGACY_ALWAYS_ON_VPN ->
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 6af8e233ba84..567c7fde9901 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -408,4 +408,5 @@
     <string name="obfuscation_info_shadowsocks_batteryusage">Attention: Shadowsocks can increase battery consumption depending on data usage, such as streaming a video.</string>
     <string name="see_full_changelog">See full changelog</string>
     <string name="changelog_empty">No changelog was added for this version</string>
+    <string name="wg_port_subtitle">Set %s obfuscation to \"Automatic\" or \"Off\" below to activate this setting.</string>
 </resources>
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt
index 86b27e3ba83d..11b6d347642e 100644
--- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt
@@ -6,54 +6,112 @@ import android.net.Network
 import android.net.NetworkCapabilities
 import android.net.NetworkRequest
 import co.touchlab.kermit.Logger
+import java.net.DatagramSocket
+import java.net.Inet4Address
+import java.net.Inet6Address
 import java.net.InetAddress
+import kotlin.collections.ArrayList
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filterIsInstance
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.scan
 import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.plus
+import net.mullvad.talpid.model.Connectivity
+import net.mullvad.talpid.model.NetworkState
+import net.mullvad.talpid.util.IPAvailabilityUtils
 import net.mullvad.talpid.util.NetworkEvent
-import net.mullvad.talpid.util.defaultNetworkFlow
-import net.mullvad.talpid.util.networkFlow
+import net.mullvad.talpid.util.RawNetworkState
+import net.mullvad.talpid.util.defaultRawNetworkStateFlow
+import net.mullvad.talpid.util.networkEvents
 
-class ConnectivityListener(val connectivityManager: ConnectivityManager) {
-    private lateinit var _isConnected: StateFlow<Boolean>
+class ConnectivityListener(
+    val connectivityManager: ConnectivityManager,
+    val protect: (socket: DatagramSocket) -> Boolean,
+) {
+    private lateinit var _isConnected: StateFlow<Connectivity>
     // Used by JNI
     val isConnected
         get() = _isConnected.value
 
-    private lateinit var _currentDnsServers: StateFlow<List<InetAddress>>
+    private lateinit var _currentNetworkState: StateFlow<NetworkState?>
+
+    // Used by JNI
+    val currentDefaultNetworkState: NetworkState?
+        get() = _currentNetworkState.value
+
     // Used by JNI
-    val currentDnsServers
-        get() = ArrayList(_currentDnsServers.value)
+    val currentDnsServers: ArrayList<InetAddress>
+        get() = _currentNetworkState.value?.dnsServers ?: ArrayList()
 
     fun register(scope: CoroutineScope) {
-        _currentDnsServers =
-            dnsServerChanges().stateIn(scope, SharingStarted.Eagerly, currentDnsServers())
+        // Consider implementing retry logic for the flows below, because registering a listener on
+        // the default network may fail if the network on Android 11
+        // https://issuetracker.google.com/issues/175055271?pli=1
+        _currentNetworkState =
+            connectivityManager
+                .defaultRawNetworkStateFlow()
+                .map { it?.toNetworkState() }
+                .onEach { notifyDefaultNetworkChange(it) }
+                .stateIn(scope, SharingStarted.Eagerly, null)
 
         _isConnected =
-            hasInternetCapability()
-                .onEach { notifyConnectivityChange(it) }
-                .stateIn(scope, SharingStarted.Eagerly, false)
+            combine(connectivityManager.defaultRawNetworkStateFlow(), hasInternetCapability()) {
+                    rawNetworkState: RawNetworkState?,
+                    hasInternetCapability: Boolean ->
+                    if (hasInternetCapability) {
+                        val isUnderlyingNetwork =
+                            rawNetworkState
+                                ?.networkCapabilities
+                                ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == true
+                        if (isUnderlyingNetwork) {
+                                // If the default network is not a VPN we can check the addresses
+                                // directly
+                                Connectivity.Status(
+                                    ipv4 =
+                                        rawNetworkState.linkProperties?.routes?.any {
+                                            it.destination.address is Inet4Address
+                                        } == true,
+                                    ipv6 =
+                                        rawNetworkState.linkProperties?.routes?.any {
+                                            it.destination.address is Inet6Address
+                                        } == true,
+                                )
+                            } else {
+                                // If the default network is a VPN we need to use a socket to check
+                                // the underlying network
+                                Connectivity.Status(
+                                    IPAvailabilityUtils.isIPv4Available(protect = { protect(it) }),
+                                    IPAvailabilityUtils.isIPv6Available(protect = { protect(it) }),
+                                )
+                            }
+                            // If we have internet, but both IPv4 and IPv6 are not available, we
+                            // assume something is wrong and instead will return presume online.
+                            .takeUnless { !it.ipv4 && !it.ipv6 } ?: Connectivity.PresumeOnline
+                    } else {
+                        Connectivity.Status(false, false)
+                    }
+                }
+                .distinctUntilChanged()
+                .onEach {
+                    when (it) {
+                        Connectivity.PresumeOnline -> notifyConnectivityChange(true, true)
+                        is Connectivity.Status -> notifyConnectivityChange(it.ipv4, it.ipv6)
+                    }
+                }
+                .stateIn(
+                    scope + Dispatchers.IO,
+                    SharingStarted.Eagerly,
+                    Connectivity.Status(false, false),
+                )
     }
 
-    private fun dnsServerChanges(): Flow<List<InetAddress>> =
-        connectivityManager
-            .defaultNetworkFlow()
-            .filterIsInstance<NetworkEvent.LinkPropertiesChanged>()
-            .onEach { Logger.d("Link properties changed") }
-            .map { it.linkProperties.dnsServersWithoutFallback() }
-
-    private fun currentDnsServers(): List<InetAddress> =
-        connectivityManager
-            .getLinkProperties(connectivityManager.activeNetwork)
-            ?.dnsServersWithoutFallback() ?: emptyList()
-
     private fun LinkProperties.dnsServersWithoutFallback(): List<InetAddress> =
         dnsServers.filter { it.hostAddress != TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER }
 
@@ -65,7 +123,7 @@ class ConnectivityListener(val connectivityManager: ConnectivityManager) {
                 .build()
 
         return connectivityManager
-            .networkFlow(request)
+            .networkEvents(request)
             .scan(setOf<Network>()) { networks, event ->
                 when (event) {
                     is NetworkEvent.Available -> {
@@ -87,5 +145,14 @@ class ConnectivityListener(val connectivityManager: ConnectivityManager) {
             .distinctUntilChanged()
     }
 
-    private external fun notifyConnectivityChange(isConnected: Boolean)
+    private fun RawNetworkState.toNetworkState(): NetworkState =
+        NetworkState(
+            network.networkHandle,
+            linkProperties?.routes,
+            linkProperties?.dnsServersWithoutFallback(),
+        )
+
+    private external fun notifyConnectivityChange(isIPv4: Boolean, isIPv6: Boolean)
+
+    private external fun notifyDefaultNetworkChange(networkState: NetworkState?)
 }
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt
index 74d44005cd7c..1feeb7c21cdf 100644
--- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt
@@ -1,18 +1,29 @@
 package net.mullvad.talpid
 
 import android.net.ConnectivityManager
+import android.net.VpnService
 import android.os.ParcelFileDescriptor
 import androidx.annotation.CallSuper
 import androidx.core.content.getSystemService
 import androidx.lifecycle.lifecycleScope
+import arrow.core.Either
+import arrow.core.mapOrAccumulate
+import arrow.core.merge
+import arrow.core.raise.either
 import co.touchlab.kermit.Logger
 import java.net.Inet4Address
 import java.net.Inet6Address
 import java.net.InetAddress
 import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.lib.common.util.establishSafe
 import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe
 import net.mullvad.mullvadvpn.lib.model.PrepareError
 import net.mullvad.talpid.model.CreateTunResult
+import net.mullvad.talpid.model.CreateTunResult.EstablishError
+import net.mullvad.talpid.model.CreateTunResult.InvalidDnsServers
+import net.mullvad.talpid.model.CreateTunResult.NotPrepared
+import net.mullvad.talpid.model.CreateTunResult.OtherAlwaysOnApp
+import net.mullvad.talpid.model.CreateTunResult.OtherLegacyAlwaysOnVpn
 import net.mullvad.talpid.model.TunConfig
 import net.mullvad.talpid.util.TalpidSdkUtils.setMeteredIfSupported
 
@@ -22,7 +33,7 @@ open class TalpidVpnService : LifecycleVpnService() {
             val oldTunFd =
                 when (oldTunStatus) {
                     is CreateTunResult.Success -> oldTunStatus.tunFd
-                    is CreateTunResult.InvalidDnsServers -> oldTunStatus.tunFd
+                    is InvalidDnsServers -> oldTunStatus.tunFd
                     else -> null
                 }
 
@@ -39,30 +50,35 @@ open class TalpidVpnService : LifecycleVpnService() {
     @CallSuper
     override fun onCreate() {
         super.onCreate()
-        connectivityListener = ConnectivityListener(getSystemService<ConnectivityManager>()!!)
+        connectivityListener =
+            ConnectivityListener(getSystemService<ConnectivityManager>()!!, ::protect)
         connectivityListener.register(lifecycleScope)
     }
 
-    fun openTun(config: TunConfig): CreateTunResult {
+    // Used by JNI
+    fun openTun(config: TunConfig): CreateTunResult =
         synchronized(this) {
             val tunStatus = activeTunStatus
 
             if (config == currentTunConfig && tunStatus != null && tunStatus.isOpen) {
-                return tunStatus
+                tunStatus
             } else {
-                return openTunImpl(config)
+                openTunImpl(config)
             }
         }
-    }
 
-    fun openTunForced(config: TunConfig): CreateTunResult {
-        synchronized(this) {
-            return openTunImpl(config)
-        }
-    }
+    // Used by JNI
+    fun openTunForced(config: TunConfig): CreateTunResult =
+        synchronized(this) { openTunImpl(config) }
+
+    // Used by JNI
+    fun closeTun(): Unit = synchronized(this) { activeTunStatus = null }
+
+    // Used by JNI
+    fun bypass(socket: Int): Boolean = protect(socket)
 
     private fun openTunImpl(config: TunConfig): CreateTunResult {
-        val newTunStatus = createTun(config)
+        val newTunStatus = createTun(config).merge()
 
         currentTunConfig = config
         activeTunStatus = newTunStatus
@@ -70,95 +86,76 @@ open class TalpidVpnService : LifecycleVpnService() {
         return newTunStatus
     }
 
-    fun closeTun() {
-        synchronized(this) { activeTunStatus = null }
-    }
-
-    // DROID-1407
-    // Function is to be cleaned up and lint suppression to be removed.
-    @Suppress("ReturnCount")
-    private fun createTun(config: TunConfig): CreateTunResult {
-        prepareVpnSafe()
-            .mapLeft { it.toCreateTunResult() }
-            .onLeft {
-                return it
+    private fun createTun(
+        config: TunConfig
+    ): Either<CreateTunResult.Error, CreateTunResult.Success> = either {
+        prepareVpnSafe().mapLeft { it.toCreateTunError() }.bind()
+
+        val builder = Builder()
+        builder.setMtu(config.mtu)
+        builder.setBlocking(false)
+        builder.setMeteredIfSupported(false)
+
+        config.addresses.forEach { builder.addAddress(it, it.prefixLength()) }
+        config.routes.forEach { builder.addRoute(it.address, it.prefixLength.toInt()) }
+        config.excludedPackages.forEach { app -> builder.addDisallowedApplication(app) }
+
+        // We don't care if adding DNS servers fails at this point, since we can still create a
+        // tunnel to consume traffic and then notify daemon to later enter blocked state.
+        val dnsConfigureResult =
+            config.dnsServers.mapOrAccumulate {
+                builder.addDnsServerSafe(it).bind()
+                Unit
             }
 
-        val invalidDnsServerAddresses = ArrayList<InetAddress>()
-
-        val builder =
-            Builder().apply {
-                for (address in config.addresses) {
-                    addAddress(address, address.prefixLength())
-                }
-
-                for (dnsServer in config.dnsServers) {
-                    try {
-                        addDnsServer(dnsServer)
-                    } catch (exception: IllegalArgumentException) {
-                        invalidDnsServerAddresses.add(dnsServer)
-                    }
-                }
-
-                // Avoids creating a tunnel with no DNS servers or if all DNS servers was invalid,
-                // since apps then may leak DNS requests.
-                // https://issuetracker.google.com/issues/337961996
-                if (invalidDnsServerAddresses.size == config.dnsServers.size) {
-                    Logger.w(
-                        "All DNS servers invalid or non set, using fallback DNS server to " +
-                            "minimize leaks, dnsServers.isEmpty(): ${config.dnsServers.isEmpty()}"
-                    )
-                    addDnsServer(FALLBACK_DUMMY_DNS_SERVER)
-                }
-
-                for (route in config.routes) {
-                    addRoute(route.address, route.prefixLength.toInt())
-                }
-
-                config.excludedPackages.forEach { app -> addDisallowedApplication(app) }
-                setMtu(config.mtu)
-                setBlocking(false)
-                setMeteredIfSupported(false)
-            }
+        // Never create a tunnel where all DNS servers are invalid or if none was ever set, since
+        // apps then may leak DNS requests.
+        // https://issuetracker.google.com/issues/337961996
+        val shouldAddFallbackDns =
+            dnsConfigureResult.fold(
+                { invalidDnsServers -> invalidDnsServers.size == config.dnsServers.size },
+                { addedDnsServers -> addedDnsServers.isEmpty() },
+            )
+        if (shouldAddFallbackDns) {
+            Logger.w(
+                "All DNS servers invalid or non set, using fallback DNS server to " +
+                    "minimize leaks, dnsServers.isEmpty(): ${config.dnsServers.isEmpty()}"
+            )
+            builder.addDnsServer(FALLBACK_DUMMY_DNS_SERVER)
+        }
 
         val vpnInterfaceFd =
-            try {
-                builder.establish()
-            } catch (e: IllegalStateException) {
-                Logger.e("Failed to establish, a parameter could not be applied", e)
-                return CreateTunResult.TunnelDeviceError
-            } catch (e: IllegalArgumentException) {
-                Logger.e("Failed to establish a parameter was not accepted", e)
-                return CreateTunResult.TunnelDeviceError
-            }
-
-        if (vpnInterfaceFd == null) {
-            Logger.e("VpnInterface returned null")
-            return CreateTunResult.TunnelDeviceError
-        }
+            builder
+                .establishSafe()
+                .onLeft { Logger.w("Failed to establish tunnel $it") }
+                .mapLeft { EstablishError }
+                .bind()
 
         val tunFd = vpnInterfaceFd.detachFd()
 
-        waitForTunnelUp(tunFd, config.routes.any { route -> route.isIpv6 })
+        dnsConfigureResult.mapLeft { InvalidDnsServers(it, tunFd) }.bind()
 
-        if (invalidDnsServerAddresses.isNotEmpty()) {
-            return CreateTunResult.InvalidDnsServers(invalidDnsServerAddresses, tunFd)
-        }
-
-        return CreateTunResult.Success(tunFd)
-    }
-
-    fun bypass(socket: Int): Boolean {
-        return protect(socket)
+        CreateTunResult.Success(tunFd)
     }
 
-    private fun PrepareError.toCreateTunResult() =
+    private fun PrepareError.toCreateTunError() =
         when (this) {
-            is PrepareError.OtherLegacyAlwaysOnVpn -> CreateTunResult.OtherLegacyAlwaysOnVpn
-            is PrepareError.NotPrepared -> CreateTunResult.NotPrepared
-            is PrepareError.OtherAlwaysOnApp -> CreateTunResult.OtherAlwaysOnApp(appName)
+            is PrepareError.OtherLegacyAlwaysOnVpn -> OtherLegacyAlwaysOnVpn
+            is PrepareError.NotPrepared -> NotPrepared
+            is PrepareError.OtherAlwaysOnApp -> OtherAlwaysOnApp(appName)
         }
 
+    private fun Builder.addDnsServerSafe(
+        dnsServer: InetAddress
+    ): Either<InetAddress, VpnService.Builder> =
+        Either.catch { addDnsServer(dnsServer) }
+            .mapLeft {
+                when (it) {
+                    is IllegalArgumentException -> dnsServer
+                    else -> throw it
+                }
+            }
+
     private fun InetAddress.prefixLength(): Int =
         when (this) {
             is Inet4Address -> IPV4_PREFIX_LENGTH
@@ -166,8 +163,6 @@ open class TalpidVpnService : LifecycleVpnService() {
             else -> throw IllegalArgumentException("Invalid IP address (not IPv4 nor IPv6)")
         }
 
-    private external fun waitForTunnelUp(tunFd: Int, isIpv6Enabled: Boolean)
-
     companion object {
         const val FALLBACK_DUMMY_DNS_SERVER = "192.0.2.1"
 
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt
new file mode 100644
index 000000000000..bdf0a44611e5
--- /dev/null
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt
@@ -0,0 +1,7 @@
+package net.mullvad.talpid.model
+
+sealed class Connectivity {
+    data class Status(val ipv4: Boolean, val ipv6: Boolean) : Connectivity()
+
+    data object PresumeOnline : Connectivity()
+}
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt
index 3cd73685f715..ef10dcd2f3b7 100644
--- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/CreateTunResult.kt
@@ -1,29 +1,38 @@
 package net.mullvad.talpid.model
 
 import java.net.InetAddress
+import java.util.ArrayList
 
-sealed class CreateTunResult {
-    open val isOpen
-        get() = false
+sealed interface CreateTunResult {
+    val isOpen: Boolean
 
-    class Success(val tunFd: Int) : CreateTunResult() {
-        override val isOpen
-            get() = true
+    data class Success(val tunFd: Int) : CreateTunResult {
+        override val isOpen = true
     }
 
-    class InvalidDnsServers(val addresses: ArrayList<InetAddress>, val tunFd: Int) :
-        CreateTunResult() {
-        override val isOpen
-            get() = true
+    sealed interface Error : CreateTunResult
+
+    // Prepare errors
+    data object OtherLegacyAlwaysOnVpn : Error {
+        override val isOpen: Boolean = false
     }
 
-    // Establish error
-    data object TunnelDeviceError : CreateTunResult()
+    data class OtherAlwaysOnApp(val appName: String) : Error {
+        override val isOpen: Boolean = false
+    }
 
-    // Prepare errors
-    data object OtherLegacyAlwaysOnVpn : CreateTunResult()
+    data object NotPrepared : Error {
+        override val isOpen: Boolean = false
+    }
 
-    data class OtherAlwaysOnApp(val appName: String) : CreateTunResult()
+    // Establish error
+    data object EstablishError : Error {
+        override val isOpen: Boolean = false
+    }
 
-    data object NotPrepared : CreateTunResult()
+    data class InvalidDnsServers(val addresses: ArrayList<InetAddress>, val tunFd: Int) : Error {
+        constructor(address: List<InetAddress>, tunFd: Int) : this(ArrayList(address), tunFd)
+
+        override val isOpen = true
+    }
 }
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt
new file mode 100644
index 000000000000..ca0b6db7e22a
--- /dev/null
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/NetworkState.kt
@@ -0,0 +1,19 @@
+package net.mullvad.talpid.model
+
+import java.net.InetAddress
+
+data class NetworkState(
+    val networkHandle: Long,
+    val routes: ArrayList<RouteInfo>?,
+    val dnsServers: ArrayList<InetAddress>?,
+) {
+    constructor(
+        networkHandle: Long,
+        routes: List<AndroidRouteInfo>?,
+        dnsServers: List<InetAddress>?,
+    ) : this(
+        networkHandle = networkHandle,
+        routes = routes?.map { it.toRoute() }?.let { ArrayList(it) },
+        dnsServers = dnsServers?.let { ArrayList(it) },
+    )
+}
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt
new file mode 100644
index 000000000000..a2b63b3ca73f
--- /dev/null
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/RouteInfo.kt
@@ -0,0 +1,18 @@
+package net.mullvad.talpid.model
+
+import java.net.InetAddress
+
+typealias AndroidRouteInfo = android.net.RouteInfo
+
+data class RouteInfo(
+    val destination: InetNetwork,
+    val gateway: InetAddress?,
+    val interfaceName: String?,
+)
+
+fun AndroidRouteInfo.toRoute() =
+    RouteInfo(
+        destination = InetNetwork(destination.address, destination.prefixLength.toShort()),
+        gateway = gateway,
+        interfaceName = `interface`,
+    )
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt
index daf155c6e8ef..fddaa6fb8806 100644
--- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt
@@ -10,59 +10,56 @@ import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.channels.trySendBlocking
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.scan
+
+internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow {
+    val callback =
+        object : NetworkCallback() {
+            override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
+                super.onLinkPropertiesChanged(network, linkProperties)
+                trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties))
+            }
 
-fun ConnectivityManager.defaultNetworkFlow(): Flow<NetworkEvent> =
-    callbackFlow<NetworkEvent> {
-        val callback =
-            object : NetworkCallback() {
-                override fun onLinkPropertiesChanged(
-                    network: Network,
-                    linkProperties: LinkProperties,
-                ) {
-                    super.onLinkPropertiesChanged(network, linkProperties)
-                    trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties))
-                }
-
-                override fun onAvailable(network: Network) {
-                    super.onAvailable(network)
-                    trySendBlocking(NetworkEvent.Available(network))
-                }
+            override fun onAvailable(network: Network) {
+                super.onAvailable(network)
+                trySendBlocking(NetworkEvent.Available(network))
+            }
 
-                override fun onCapabilitiesChanged(
-                    network: Network,
-                    networkCapabilities: NetworkCapabilities,
-                ) {
-                    super.onCapabilitiesChanged(network, networkCapabilities)
-                    trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities))
-                }
+            override fun onCapabilitiesChanged(
+                network: Network,
+                networkCapabilities: NetworkCapabilities,
+            ) {
+                super.onCapabilitiesChanged(network, networkCapabilities)
+                trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities))
+            }
 
-                override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
-                    super.onBlockedStatusChanged(network, blocked)
-                    trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked))
-                }
+            override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
+                super.onBlockedStatusChanged(network, blocked)
+                trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked))
+            }
 
-                override fun onLosing(network: Network, maxMsToLive: Int) {
-                    super.onLosing(network, maxMsToLive)
-                    trySendBlocking(NetworkEvent.Losing(network, maxMsToLive))
-                }
+            override fun onLosing(network: Network, maxMsToLive: Int) {
+                super.onLosing(network, maxMsToLive)
+                trySendBlocking(NetworkEvent.Losing(network, maxMsToLive))
+            }
 
-                override fun onLost(network: Network) {
-                    super.onLost(network)
-                    trySendBlocking(NetworkEvent.Lost(network))
-                }
+            override fun onLost(network: Network) {
+                super.onLost(network)
+                trySendBlocking(NetworkEvent.Lost(network))
+            }
 
-                override fun onUnavailable() {
-                    super.onUnavailable()
-                    trySendBlocking(NetworkEvent.Unavailable)
-                }
+            override fun onUnavailable() {
+                super.onUnavailable()
+                trySendBlocking(NetworkEvent.Unavailable)
             }
-        registerDefaultNetworkCallback(callback)
+        }
+    registerDefaultNetworkCallback(callback)
 
-        awaitClose { unregisterNetworkCallback(callback) }
-    }
+    awaitClose { unregisterNetworkCallback(callback) }
+}
 
-fun ConnectivityManager.networkFlow(networkRequest: NetworkRequest): Flow<NetworkEvent> =
-    callbackFlow<NetworkEvent> {
+fun ConnectivityManager.networkEvents(networkRequest: NetworkRequest): Flow<NetworkEvent> =
+    callbackFlow {
         val callback =
             object : NetworkCallback() {
                 override fun onLinkPropertiesChanged(
@@ -111,6 +108,26 @@ fun ConnectivityManager.networkFlow(networkRequest: NetworkRequest): Flow<Networ
         awaitClose { unregisterNetworkCallback(callback) }
     }
 
+internal fun ConnectivityManager.defaultRawNetworkStateFlow(): Flow<RawNetworkState?> =
+    defaultNetworkEvents()
+        .scan(
+            null as RawNetworkState?,
+            { state, event ->
+                return@scan when (event) {
+                    is NetworkEvent.Available -> RawNetworkState(network = event.network)
+                    is NetworkEvent.BlockedStatusChanged ->
+                        state?.copy(blockedStatus = event.blocked)
+                    is NetworkEvent.CapabilitiesChanged ->
+                        state?.copy(networkCapabilities = event.networkCapabilities)
+                    is NetworkEvent.LinkPropertiesChanged ->
+                        state?.copy(linkProperties = event.linkProperties)
+                    is NetworkEvent.Losing -> state?.copy(maxMsToLive = event.maxMsToLive)
+                    is NetworkEvent.Lost -> null
+                    NetworkEvent.Unavailable -> null
+                }
+            },
+        )
+
 sealed interface NetworkEvent {
     data class Available(val network: Network) : NetworkEvent
 
@@ -130,3 +147,11 @@ sealed interface NetworkEvent {
 
     data class Lost(val network: Network) : NetworkEvent
 }
+
+internal data class RawNetworkState(
+    val network: Network,
+    val linkProperties: LinkProperties? = null,
+    val networkCapabilities: NetworkCapabilities? = null,
+    val blockedStatus: Boolean = false,
+    val maxMsToLive: Int? = null,
+)
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt
new file mode 100644
index 000000000000..425a578307eb
--- /dev/null
+++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/IPAvailabilityUtils.kt
@@ -0,0 +1,46 @@
+package net.mullvad.talpid.util
+
+import co.touchlab.kermit.Logger
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.SocketException
+
+object IPAvailabilityUtils {
+    fun isIPv4Available(protect: (socket: DatagramSocket) -> Boolean): Boolean =
+        isIPAvailable(InetAddress.getByName(PUBLIC_IPV4_ADDRESS), protect)
+
+    fun isIPv6Available(protect: (socket: DatagramSocket) -> Boolean): Boolean =
+        isIPAvailable(InetAddress.getByName(PUBLIC_IPV6_ADDRESS), protect)
+
+    // Fake a connection to a public ip address using a UDP socket.
+    // We don't care about the result of the connection, only that it is possible to create.
+    // This is done this way since otherwise there is not way to check the availability of an ip
+    // version on the underlying network if the VPN is turned on.
+    // Since we are protecting the socket it will use the underlying network regardless
+    // if the VPN is turned on or not.
+    // If the ip version is not supported on the underlying network it will trigger a socket
+    // exception. Otherwise we assume it is available.
+    private inline fun <reified T : InetAddress> isIPAvailable(
+        ip: T,
+        protect: (socket: DatagramSocket) -> Boolean,
+    ): Boolean {
+        val socket = DatagramSocket()
+        if (!protect(socket)) {
+            Logger.e("Unable to protect the socket VPN is not set up correctly")
+            return false
+        }
+        return try {
+            socket.connect(InetSocketAddress(ip, 1))
+            true
+        } catch (_: SocketException) {
+            Logger.e("Socket could not be set up")
+            false
+        } finally {
+            socket.close()
+        }
+    }
+
+    private const val PUBLIC_IPV4_ADDRESS = "1.1.1.1"
+    private const val PUBLIC_IPV6_ADDRESS = "2606:4700:4700::1001"
+}
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
index f38602bf161a..9eb0aae4b202 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt
@@ -38,7 +38,6 @@ private val MullvadTypography =
         headlineSmall = TextStyle(fontSize = TypeScale.TextBig, fontWeight = FontWeight.Bold),
         bodySmall = TextStyle(fontSize = TypeScale.TextSmall),
         titleSmall = TextStyle(fontSize = TypeScale.TextMedium, fontWeight = FontWeight.SemiBold),
-        bodyMedium = TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.Bold),
         titleMedium =
             TextStyle(fontSize = TypeScale.TextMediumPlus, fontWeight = FontWeight.SemiBold),
         titleLarge = TextStyle(fontSize = TypeScale.TitleLarge, fontFamily = FontFamily.SansSerif),
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index 6a5da5c18d31..51b4f5efb895 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -16,10 +16,9 @@ data class Dimensions(
     val cellFooterTopPadding: Dp = 6.dp,
     val cellHeight: Dp = 56.dp,
     val cellHeightTwoRows: Dp = 72.dp,
-    val cellLabelVerticalPadding: Dp = 14.dp,
     val cellStartPadding: Dp = 14.dp,
     val cellTopPadding: Dp = 6.dp,
-    val cellVerticalSpacing: Dp = 14.dp,
+    val cellVerticalSpacing: Dp = 24.dp,
     val chipSpace: Dp = 8.dp,
     val chipVerticalPadding: Dp = 4.dp,
     val circularProgressBarLargeSize: Dp = 44.dp,
diff --git a/ci/buildserver-build-android.sh b/ci/buildserver-build-android.sh
index 0010161eadf3..a2eb39a1121b 100755
--- a/ci/buildserver-build-android.sh
+++ b/ci/buildserver-build-android.sh
@@ -65,7 +65,7 @@ function checkout_ref {
     git reset --hard
     git checkout "$ref"
     git submodule update
-    git submodule update --init --recursive --depth=1 wireguard-go-rs || true
+    git submodule update --init wireguard-go-rs/libwg/wireguard-go || true
     git clean -df
 }
 
diff --git a/ci/buildserver-build.sh b/ci/buildserver-build.sh
index b68bd79e77a6..d932f6825dc6 100755
--- a/ci/buildserver-build.sh
+++ b/ci/buildserver-build.sh
@@ -159,7 +159,7 @@ function checkout_ref {
     git reset --hard
     git checkout "$ref"
     git submodule update
-    git submodule update --init --recursive --depth=1 wireguard-go-rs || true
+    git submodule update --init wireguard-go-rs/libwg/wireguard-go || true
     git clean -df
 }
 
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index bc2c4084ad12..fc1c088646f5 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -2031,9 +2031,9 @@
       "dev": true
     },
     "node_modules/@types/node": {
-      "version": "20.17.9",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz",
-      "integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==",
+      "version": "20.17.17",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz",
+      "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==",
       "license": "MIT",
       "dependencies": {
         "undici-types": "~6.19.2"
@@ -5375,11 +5375,12 @@
       }
     },
     "node_modules/electron": {
-      "version": "33.2.1",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz",
-      "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==",
+      "version": "33.4.0",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.0.tgz",
+      "integrity": "sha512-AO+Q/ygWwKKs+JtNEFgfS5ntjG3TA2HX7s4IEbiYi6lktaocuLP2oScG1/mmKRuUWoOcow2RRsf995L2mM4bvQ==",
       "dev": true,
       "hasInstallScript": true,
+      "license": "MIT",
       "dependencies": {
         "@electron/get": "^2.0.0",
         "@types/node": "^20.9.0",
@@ -14691,7 +14692,7 @@
         "chai-as-promised": "^7.1.1",
         "chai-spies": "^1.0.0",
         "cross-env": "^7.0.3",
-        "electron": "33.2.1",
+        "electron": "33.4.0",
         "electron-builder": "^24.13.3",
         "electron-devtools-installer": "^3.2.0",
         "eslint-plugin-react": "^7.36.1",
@@ -16285,9 +16286,9 @@
       "dev": true
     },
     "@types/node": {
-      "version": "20.17.9",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz",
-      "integrity": "sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw==",
+      "version": "20.17.17",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.17.tgz",
+      "integrity": "sha512-/WndGO4kIfMicEQLTi/mDANUu/iVUhT7KboZPdEqqHQ4aTS+3qT3U5gIqWDFV+XouorjfgGqvKILJeHhuQgFYg==",
       "requires": {
         "undici-types": "~6.19.2"
       }
@@ -18948,9 +18949,9 @@
       }
     },
     "electron": {
-      "version": "33.2.1",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz",
-      "integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==",
+      "version": "33.4.0",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.0.tgz",
+      "integrity": "sha512-AO+Q/ygWwKKs+JtNEFgfS5ntjG3TA2HX7s4IEbiYi6lktaocuLP2oScG1/mmKRuUWoOcow2RRsf995L2mM4bvQ==",
       "dev": true,
       "requires": {
         "@electron/get": "^2.0.0",
@@ -22573,7 +22574,7 @@
         "chai-as-promised": "^7.1.1",
         "chai-spies": "^1.0.0",
         "cross-env": "^7.0.3",
-        "electron": "33.2.1",
+        "electron": "33.4.0",
         "electron-builder": "^24.13.3",
         "electron-devtools-installer": "^3.2.0",
         "eslint-plugin-react": "^7.36.1",
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index dd5747d6ad6c..0157e0c32fe0 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -2638,6 +2638,9 @@ msgstr ""
 msgid "See full changelog"
 msgstr ""
 
+msgid "Set %s obfuscation to \"Automatic\" or \"Off\" below to activate this setting."
+msgstr ""
+
 msgid "Set WireGuard MTU value. Valid range: %d - %d."
 msgstr ""
 
diff --git a/desktop/packages/mullvad-vpn/package.json b/desktop/packages/mullvad-vpn/package.json
index 1b070ac9dbb7..056b0be93fcf 100644
--- a/desktop/packages/mullvad-vpn/package.json
+++ b/desktop/packages/mullvad-vpn/package.json
@@ -50,7 +50,7 @@
     "chai-as-promised": "^7.1.1",
     "chai-spies": "^1.0.0",
     "cross-env": "^7.0.3",
-    "electron": "33.2.1",
+    "electron": "33.4.0",
     "electron-builder": "^24.13.3",
     "electron-devtools-installer": "^3.2.0",
     "eslint-plugin-react": "^7.36.1",
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts
index 679951ec6782..50808598f414 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/foundations/variables/color-variables.ts
@@ -1,7 +1,7 @@
 import { ColorTokens } from '../tokens';
 
 export const colors = {
-  '--color-darker-blue': ColorTokens.darkBlue,
+  '--color-darker-blue': ColorTokens.darkerBlue,
   '--color-dark-blue': ColorTokens.darkBlue,
   '--color-blue': ColorTokens.blue,
   '--color-dark-green': ColorTokens.darkGreen,
diff --git a/desktop/scripts/prepare-release.sh b/desktop/scripts/release/1-prepare-release
similarity index 68%
rename from desktop/scripts/prepare-release.sh
rename to desktop/scripts/release/1-prepare-release
index 18859d260d51..4becc19d0608 100755
--- a/desktop/scripts/prepare-release.sh
+++ b/desktop/scripts/release/1-prepare-release
@@ -1,22 +1,21 @@
 #!/usr/bin/env bash
 
 # This script prepares for a release. Run it with the release version as the first argument and it
-# will update version numbers, commit and add a signed tag.
+# will update version numbers, update the changelog, and update copyright year.
 
 set -eu
 
 SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 cd "$SCRIPT_DIR"
 
-REPO_ROOT=../../
+REPO_ROOT=../../../
 
 source $REPO_ROOT/scripts/utils/log
+source $REPO_ROOT/scripts/utils/print-and-run
 
-PUSH_TAG="false"
 
 for argument in "$@"; do
     case "$argument" in
-        --push-tag) PUSH_TAG="true" ;;
         -*)
             log_error "Unknown option \"$argument\""
             exit 1
@@ -27,15 +26,10 @@ for argument in "$@"; do
     esac
 done
 
-changes_path=../packages/mullvad-vpn/changes.txt
+changes_path=$REPO_ROOT/desktop/packages/mullvad-vpn/changes.txt
 changelog_path=$REPO_ROOT/CHANGELOG.md
 product_version_path=$REPO_ROOT/dist-assets/desktop-product-version.txt
 
-function print_and_run {
-    echo "+ $*"
-    "$@"
-}
-
 function checks {
     if [[ -z ${PRODUCT_VERSION+x} ]]; then
         log_error "Please give the release version as an argument to this script."
@@ -88,7 +82,9 @@ function check_changelog {
 
 function update_copyright_year {
     $REPO_ROOT/scripts/update-copyright
-    print_and_run git commit -A -S -m "Update copyright year in project files and code"
+    if [[ $(git diff --shortstat 2> /dev/null | tail -n1) != "" ]]; then
+        print_and_run git commit -a -S -m "Update copyright year in project files and code"
+    fi
 }
 
 function update_changelog {
@@ -107,31 +103,18 @@ function update_product_version {
         $product_version_path
 }
 
-function push_tag {
-    product_version=$(echo -n "$(cat $product_version_path)")
-    echo "Tagging current git commit with release tag $product_version..."
-    print_and_run git tag -s "$product_version" -m "$product_version"
-    print_and_run git push origin "$product_version"
-    log_success "\nTag pushed!"
-}
-
-if [[ $PUSH_TAG == "true" ]]; then
-    check_commit_signature
-    push_tag
-else
-    checks
-    check_commit_signature
-    check_changelog
-    update_changelog
-    update_copyright_year
-    update_product_version
-
-    log_success "\n================================================="
-    log_success "| DONE preparing for a release!                 |"
-    log_success "|    Now verify that everything looks correct   |"
-    log_success "|    and then create and push the tag by        |"
-    log_success "|    running:                                   |"
-    log_success "|    $ $0 \\ "
-    log_success "|        --push-tag                             |"
-    log_success "================================================="
-fi
+checks
+check_commit_signature
+check_changelog
+update_changelog
+update_copyright_year
+update_product_version
+
+log_success "\n================================================="
+log_success "| DONE preparing for a release!                 |"
+log_success "|    Now verify that everything looks correct   |"
+log_success "|    and then create and push the tag by        |"
+log_success "|    running:                                   |"
+log_success "|    $ $0 \\ "
+log_success "|        --push-tag                             |"
+log_success "================================================="
diff --git a/desktop/scripts/release/2-push-release-tag b/desktop/scripts/release/2-push-release-tag
new file mode 100755
index 000000000000..a5115ed860c3
--- /dev/null
+++ b/desktop/scripts/release/2-push-release-tag
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+# This script creates and pushes a signed release tag. This should be run after `1-prepare-release`.
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+REPO_ROOT=../../../
+PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt
+PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH)
+
+source $REPO_ROOT/scripts/utils/print-and-run
+source $REPO_ROOT/scripts/utils/log
+
+function push_tag {
+    product_version=$(echo -n "$PRODUCT_VERSION")
+    echo "Tagging current git commit with release tag $product_version..."
+    print_and_run git tag -s "$product_version" -m "$product_version"
+    git push
+    print_and_run git push origin "$product_version"
+    log_success "\nTag pushed!"
+}
+
+git verify-commit HEAD
+push_tag
diff --git a/desktop/scripts/release/3-verify-build b/desktop/scripts/release/3-verify-build
new file mode 100755
index 000000000000..be8ebde50dc1
--- /dev/null
+++ b/desktop/scripts/release/3-verify-build
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+
+# This script verifies the build produced by the buildserver. It helps the user verify the staging
+# repository versions and triggers a e2e run with a small subset of the tests to verify the build.
+# This should be be run after `2-push-release-tag` and after the build server has finished building.
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+REPO_ROOT=../../../
+PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt
+PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH)
+
+$REPO_ROOT/scripts/utils/gh-ready-check
+source $REPO_ROOT/scripts/utils/log
+
+function verify_repository_versions {
+  print_versions_args=( --staging )
+
+  if [[ "$PRODUCT_VERSION" == *-beta* ]]; then
+    print_versions_args+=( --beta )
+  fi
+
+  ./print-package-versions "${print_versions_args[@]}"
+  read -r -n 1 -p "Does the versions look correct? (y/N): " response
+  printf "\n\n"
+
+  if [[ "$response" =~ ^[Yy]$ ]]; then
+      return
+  elif [[ "$response" =~ ^[Nn]$ ]]; then
+      log_info "Aborting"
+      exit 1
+  else
+      log_error "Invalid response"
+      exit 1
+  fi
+}
+
+verify_repository_versions
+gh workflow run desktop-e2e.yml --ref "$PRODUCT_VERSION" \
+    -f oses="fedora41 ubuntu2404 windows11 macos15" \
+    -f tests="test_quantum_resistant_tunnel test_ui_tunnel_settings"
diff --git a/desktop/scripts/make-release b/desktop/scripts/release/4-make-release
similarity index 87%
rename from desktop/scripts/make-release
rename to desktop/scripts/release/4-make-release
index 8af3a94119f2..0f421e30b140 100755
--- a/desktop/scripts/make-release
+++ b/desktop/scripts/release/4-make-release
@@ -1,23 +1,19 @@
 #!/usr/bin/env bash
 
+# This script downloads the build artifacts along with the signatures, verifies the signatures and
+# creates a GitHub draft release. This should be run after `3-verify-build`.
+
 set -eu
 
-if ! command -v gh > /dev/null; then
-    echo >&2 "gh (GitHub CLI) is required to run this script"
-    exit 1
-fi
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
 
-if ! gh auth status > /dev/null; then
-    echo >&2 "Authentication through gh (GitHub CLI) is required to run this script"
-    exit 1
-fi
+REPO_ROOT=../../../
+PRODUCT_VERSION_PATH=$REPO_ROOT/dist-assets/desktop-product-version.txt
+PRODUCT_VERSION=$(cat $PRODUCT_VERSION_PATH)
 
-if [[ $# != 1 ]]; then
-    echo "!!! Please pass the app version as the first and only argument"
-    exit 1
-fi
+$REPO_ROOT/scripts/utils/gh-ready-check
 
-PRODUCT_VERSION=$1
 REPO_URL="git@github.com:mullvad/mullvadvpn-app"
 ARTIFACT_DIR=$(mktemp -d)
 REPO_DIR=$(mktemp -d)
diff --git a/desktop/scripts/print-package-versions.sh b/desktop/scripts/release/print-package-versions
similarity index 96%
rename from desktop/scripts/print-package-versions.sh
rename to desktop/scripts/release/print-package-versions
index 9d255f5fac47..2170f5417c31 100755
--- a/desktop/scripts/print-package-versions.sh
+++ b/desktop/scripts/release/print-package-versions
@@ -27,9 +27,9 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 cd "$SCRIPT_DIR"
 
 # shellcheck source=ci/linux-repository-builder/build-linux-repositories-config.sh
-source build-linux-repositories-config.sh
+source ../../../ci/linux-repository-builder/build-linux-repositories-config.sh
 # shellcheck source=scripts/utils/log
-source ../../scripts/utils/log
+source ../../../scripts/utils/log
 
 deb="false"
 rpm="false"
diff --git a/desktop/scripts/test-release-artifacts b/desktop/scripts/test-release-artifacts
deleted file mode 100755
index fb5ffe6ccbfa..000000000000
--- a/desktop/scripts/test-release-artifacts
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/usr/bin/env bash
-
-set -eu
-
-if ! command -v gh > /dev/null; then
-    echo >&2 "gh (GitHub CLI) is required to run this script"
-    exit 1
-fi
-
-if ! gh auth status > /dev/null; then
-    echo >&2 "Authentication through gh (GitHub CLI) is required to run this script"
-    exit 1
-fi
-
-if [[ $# != 1 ]]; then
-    echo "!!! Please pass the app version as the first and only argument"
-    exit 1
-fi
-
-PRODUCT_VERSION=$1
-gh workflow run desktop-e2e.yml --ref "$PRODUCT_VERSION" \
-    -f oses="fedora41 ubuntu2404 windows11 macos15" \
-    -f tests="test_quantum_resistant_tunnel test_ui_tunnel_settings"
diff --git a/dist-assets/desktop-product-version.txt b/dist-assets/desktop-product-version.txt
index ff47b30c5bdd..68c0b64f7fc3 100644
--- a/dist-assets/desktop-product-version.txt
+++ b/dist-assets/desktop-product-version.txt
@@ -1 +1 @@
-2025.3-beta1
+2025.3
diff --git a/ios/Configurations/UITests.xcconfig.template b/ios/Configurations/UITests.xcconfig.template
index af31a89b2664..988a9a65d786 100644
--- a/ios/Configurations/UITests.xcconfig.template
+++ b/ios/Configurations/UITests.xcconfig.template
@@ -23,8 +23,11 @@ AD_SERVING_DOMAIN = vpnlist.to
 
 // A domain which should be reachable. Used to verify Internet connectivity. Must be running a server on port 80.
 SHOULD_BE_REACHABLE_DOMAIN = mullvad.net
+
+// An IP address which should always be reachable. Must be running a server on port 80.
+SHOULD_BE_REACHABLE_IP_ADDRESS = 45.83.223.209
  
-// Base URL for the firewall API, Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
+// Base URL for the firewall API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
 FIREWALL_API_BASE_URL = http:/${}/8.8.8.8
 
 // URL for Mullvad provided JSON data with information about the connection. https://am.i.mullvad.net/json for production, https://am.i.stagemole.eu/json for staging.
@@ -32,3 +35,6 @@ AM_I_JSON_URL = https:/${}/am.i.stagemole.eu/json
 
 // Specify whether app logs should be extracted and attached to test report for failing tests
 ATTACH_APP_LOGS_ON_FAILURE = 0
+
+// Base URL for the packet capture API. Note that // will be treated as a comment, therefor you need to insert a ${} between the slashes for example http:/${}/8.8.8.8
+PACKET_CAPTURE_BASE_URL = http:/${}/8.8.8.8
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 25a47cd2ee74..76e944a9c751 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -687,21 +687,25 @@
 		8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */; };
 		8542CE242B95F7B9006FCA14 /* VPNSettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */; };
 		8542F7532BCFBD050035C042 /* SelectLocationFilterPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */; };
-		85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */; };
+		85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0D2B591B2600795FE1 /* FirewallClient.swift */; };
 		85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B0F2B59215F00795FE1 /* FirewallRule.swift */; };
 		85557B122B594FC900795FE1 /* ConnectivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B112B594FC900795FE1 /* ConnectivityTests.swift */; };
 		85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */; };
 		85557B1E2B5FB8C700795FE1 /* HeaderBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */; };
 		85557B202B5FBBD700795FE1 /* AccountPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B1F2B5FBBD700795FE1 /* AccountPage.swift */; };
+		8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */; };
 		8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */; };
 		8556EB542B9A1D7100D26DD4 /* BridgingHeader.h in Headers */ = {isa = PBXBuildFile; fileRef = 8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */; };
 		8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */; };
 		855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */; };
+		85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */; };
 		856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */; };
 		856952E22BD6B04C008C1F84 /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */; };
 		8585CBE32BC684180015B6A4 /* EditAccessMethodPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */; };
 		8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8587A05C2B84D43100152938 /* ChangeLogAlert.swift */; };
 		8590896F2B61763B003AF5F5 /* LoggedOutUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */; };
+		8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */; };
+		85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85978A532BE0F10E00F999A7 /* PacketCapture.swift */; };
 		85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */; };
 		85B267612B849ADB0098E3CD /* mullvad-api.h in Headers */ = {isa = PBXBuildFile; fileRef = 85B267602B849ADB0098E3CD /* mullvad-api.h */; };
 		85C7A2E92B89024B00035D5A /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85C7A2E82B89024B00035D5A /* SettingsTests.swift */; };
@@ -709,6 +713,7 @@
 		85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */; };
 		85E3BDE52B70E18C00FA71FD /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85E3BDE42B70E18C00FA71FD /* Networking.swift */; };
 		85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */; };
+		85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; };
 		85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; };
 		85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; };
 		A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */; };
@@ -2082,17 +2087,19 @@
 		8532E6862B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportSubmittedPage.swift; sourceTree = "<group>"; };
 		8542CE232B95F7B9006FCA14 /* VPNSettingsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsPage.swift; sourceTree = "<group>"; };
 		8542F7522BCFBD050035C042 /* SelectLocationFilterPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectLocationFilterPage.swift; sourceTree = "<group>"; };
-		85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallAPIClient.swift; sourceTree = "<group>"; };
+		85557B0D2B591B2600795FE1 /* FirewallClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallClient.swift; sourceTree = "<group>"; };
 		85557B0F2B59215F00795FE1 /* FirewallRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirewallRule.swift; sourceTree = "<group>"; };
 		85557B112B594FC900795FE1 /* ConnectivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityTests.swift; sourceTree = "<group>"; };
 		85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadAPIWrapper.swift; sourceTree = "<group>"; };
 		85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElementQuery+Extensions.swift"; sourceTree = "<group>"; };
 		85557B1D2B5FB8C700795FE1 /* HeaderBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBar.swift; sourceTree = "<group>"; };
 		85557B1F2B5FBBD700795FE1 /* AccountPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPage.swift; sourceTree = "<group>"; };
+		8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakCheck.swift; sourceTree = "<group>"; };
 		8556EB512B9A1C6900D26DD4 /* MullvadApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MullvadApi.swift; path = MullvadVPNUITests/MullvadApi.swift; sourceTree = "<group>"; };
 		8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BridgingHeader.h; sourceTree = "<group>"; };
 		8556EB552B9B0AC500D26DD4 /* RevokedDevicePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevokedDevicePage.swift; sourceTree = "<group>"; };
 		855D9F5A2B63E56B00D7C64D /* ProblemReportPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportPage.swift; sourceTree = "<group>"; };
+		85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRouterAPIClient.swift; sourceTree = "<group>"; };
 		856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerAPIClient.swift; sourceTree = "<group>"; };
 		856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Extensions.swift"; sourceTree = "<group>"; };
 		8585CBE22BC684180015B6A4 /* EditAccessMethodPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditAccessMethodPage.swift; sourceTree = "<group>"; };
@@ -2100,11 +2107,14 @@
 		859089692B61763B003AF5F5 /* LoggedInWithTimeUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedInWithTimeUITestCase.swift; sourceTree = "<group>"; };
 		8590896A2B61763B003AF5F5 /* BaseUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseUITestCase.swift; sourceTree = "<group>"; };
 		8590896B2B61763B003AF5F5 /* LoggedOutUITestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggedOutUITestCase.swift; sourceTree = "<group>"; };
+		8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficGenerator.swift; sourceTree = "<group>"; };
+		85978A532BE0F10E00F999A7 /* PacketCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketCapture.swift; sourceTree = "<group>"; };
 		85A42B872BB44D31007BABF7 /* DeviceManagementPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceManagementPage.swift; sourceTree = "<group>"; };
 		85B267602B849ADB0098E3CD /* mullvad-api.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "mullvad-api.h"; path = "../../mullvad-api/include/mullvad-api.h"; sourceTree = "<group>"; };
 		85C7A2E82B89024B00035D5A /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = "<group>"; };
 		85D039972BA4711800940E7F /* SettingsMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationTests.swift; sourceTree = "<group>"; };
 		85E3BDE42B70E18C00FA71FD /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = "<group>"; };
+		85F1E17D2C0A256200DB8F55 /* LeakTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeakTests.swift; sourceTree = "<group>"; };
 		85FB5A0B2B6903990015DCED /* WelcomePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePage.swift; sourceTree = "<group>"; };
 		85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionPage.swift; sourceTree = "<group>"; };
 		A900E9B72ACC5C2B00C95F67 /* AccountsProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountsProxy+Stubs.swift"; sourceTree = "<group>"; };
@@ -4203,21 +4213,22 @@
 		852969262B4D9C1F007EAD4C /* MullvadVPNUITests */ = {
 			isa = PBXGroup;
 			children = (
-				85557B0C2B591B0F00795FE1 /* Networking */,
-				852969312B4E9220007EAD4C /* Pages */,
-				7A45CFCD2C08697100D80B21 /* Screenshots */,
-				852969372B4ED20E007EAD4C /* Info.plist */,
 				8556EB532B9A1D7100D26DD4 /* BridgingHeader.h */,
 				85B267602B849ADB0098E3CD /* mullvad-api.h */,
+				852969372B4ED20E007EAD4C /* Info.plist */,
 				852969272B4D9C1F007EAD4C /* AccountTests.swift */,
 				85557B112B594FC900795FE1 /* ConnectivityTests.swift */,
 				A9BFAFFE2BD004ED00F2BCA1 /* CustomListsTests.swift */,
+				85F1E17D2C0A256200DB8F55 /* LeakTests.swift */,
 				850201DA2B503D7700EF8C96 /* RelayTests.swift */,
 				85D039972BA4711800940E7F /* SettingsMigrationTests.swift */,
 				85C7A2E82B89024B00035D5A /* SettingsTests.swift */,
-				8518F6392B601910009EB113 /* Base */,
 				856952E12BD6B04C008C1F84 /* XCUIElement+Extensions.swift */,
 				85557B152B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift */,
+				8518F6392B601910009EB113 /* Base */,
+				85557B0C2B591B0F00795FE1 /* Networking */,
+				852969312B4E9220007EAD4C /* Pages */,
+				7A45CFCD2C08697100D80B21 /* Screenshots */,
 			);
 			path = MullvadVPNUITests;
 			sourceTree = "<group>";
@@ -4265,11 +4276,15 @@
 		85557B0C2B591B0F00795FE1 /* Networking */ = {
 			isa = PBXGroup;
 			children = (
-				85557B0D2B591B2600795FE1 /* FirewallAPIClient.swift */,
+				85557B0D2B591B2600795FE1 /* FirewallClient.swift */,
 				85557B0F2B59215F00795FE1 /* FirewallRule.swift */,
+				8555C65F2D102FFE0092DAD0 /* LeakCheck.swift */,
 				85557B132B5983CF00795FE1 /* MullvadAPIWrapper.swift */,
 				85E3BDE42B70E18C00FA71FD /* Networking.swift */,
+				85978A532BE0F10E00F999A7 /* PacketCapture.swift */,
 				856952DB2BD2922A008C1F84 /* PartnerAPIClient.swift */,
+				85607C882D131CCD00037E34 /* TestRouterAPIClient.swift */,
+				8590A5432C2AF43400B9BF7B /* TrafficGenerator.swift */,
 			);
 			path = Networking;
 			sourceTree = "<group>";
@@ -6486,6 +6501,7 @@
 				8556EB522B9A1C6900D26DD4 /* MullvadApi.swift in Sources */,
 				85EC620C2B838D10005AFFB5 /* MullvadAPIWrapper.swift in Sources */,
 				A9DF789D2B7D1E8B0094E4AD /* LoggedInWithTimeUITestCase.swift in Sources */,
+				85607C892D131CD500037E34 /* TestRouterAPIClient.swift in Sources */,
 				85D2B0B12B6BD32400DF9DA7 /* BaseUITestCase.swift in Sources */,
 				F09084682C6E88ED001CD36E /* DaitaPromptAlert.swift in Sources */,
 				8529693C2B4F0257007EAD4C /* Alert.swift in Sources */,
@@ -6508,20 +6524,24 @@
 				852969352B4E9270007EAD4C /* LoginPage.swift in Sources */,
 				A998DA832BD2B055001D61A2 /* EditCustomListLocationsPage.swift in Sources */,
 				7A8A19242CF4C9BF000BCB5B /* MultihopPage.swift in Sources */,
+				8590A5442C2AF43400B9BF7B /* TrafficGenerator.swift in Sources */,
 				7ACD79392C0DAADD00DBEE14 /* AddCustomListLocationsPage.swift in Sources */,
 				8556EB562B9B0AC500D26DD4 /* RevokedDevicePage.swift in Sources */,
 				A9BFAFFF2BD004ED00F2BCA1 /* CustomListsTests.swift in Sources */,
 				85557B102B59215F00795FE1 /* FirewallRule.swift in Sources */,
-				85557B0E2B591B2600795FE1 /* FirewallAPIClient.swift in Sources */,
+				85557B0E2B591B2600795FE1 /* FirewallClient.swift in Sources */,
 				852969282B4D9C1F007EAD4C /* AccountTests.swift in Sources */,
 				8587A05D2B84D43100152938 /* ChangeLogAlert.swift in Sources */,
 				85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */,
 				7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */,
 				7A8A19262CF4D37B000BCB5B /* DAITAPage.swift in Sources */,
 				856952DC2BD2922A008C1F84 /* PartnerAPIClient.swift in Sources */,
+				85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */,
 				85557B162B5ABBBE00795FE1 /* XCUIElementQuery+Extensions.swift in Sources */,
 				855D9F5B2B63E56B00D7C64D /* ProblemReportPage.swift in Sources */,
 				8529693A2B4F0238007EAD4C /* TermsOfServicePage.swift in Sources */,
+				85978A542BE0F10E00F999A7 /* PacketCapture.swift in Sources */,
+				8555C6602D1030040092DAD0 /* LeakCheck.swift in Sources */,
 				85A42B882BB44D31007BABF7 /* DeviceManagementPage.swift in Sources */,
 				8532E6872B8CCED600ACECD1 /* ProblemReportSubmittedPage.swift in Sources */,
 				85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */,
diff --git a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift
index 710a689c56e3..0752fee599f8 100644
--- a/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift
+++ b/ios/MullvadVPN/Coordinators/Settings/APIAccess/AboutViewController.swift
@@ -57,7 +57,7 @@ class AboutViewController: UIViewController {
 
             label.text = header
             label.font = .systemFont(ofSize: 28, weight: .bold)
-            label.textColor = .white
+            label.textColor = .primaryTextColor
             label.numberOfLines = 0
             label.textAlignment = .center
 
@@ -70,7 +70,7 @@ class AboutViewController: UIViewController {
 
             label.text = preamble
             label.font = .systemFont(ofSize: 18)
-            label.textColor = .white
+            label.textColor = .primaryTextColor
             label.numberOfLines = 0
             label.textAlignment = .center
 
@@ -83,7 +83,7 @@ class AboutViewController: UIViewController {
 
             label.text = text
             label.font = .systemFont(ofSize: 15)
-            label.textColor = .white
+            label.textColor = .secondaryTextColor
             label.numberOfLines = 0
 
             contentView.addArrangedSubview(label)
diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift
index 43db6d09cdb2..6e43027a95e0 100644
--- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideCoordinator.swift
@@ -52,7 +52,7 @@ extension IPOverrideCoordinator: @preconcurrency IPOverrideViewControllerDelegat
         let header = NSLocalizedString(
             "IP_OVERRIDE_HEADER",
             tableName: "IPOverride",
-            value: "IP Override",
+            value: "Server IP override",
             comment: ""
         )
         let body = [
diff --git a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift
index 57e5cac3f306..5ccbf827440e 100644
--- a/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift
+++ b/ios/MullvadVPN/Coordinators/Settings/IPOverride/IPOverrideViewController.swift
@@ -82,15 +82,15 @@ class IPOverrideViewController: UIViewController {
 
     private func addHeaderView() {
         let body = NSLocalizedString(
-            "ACCESS_METHOD_HEADER_BODY",
-            tableName: "APIAccess",
-            value: "Manage default and setup custom methods to access the Mullvad API.",
+            "IP_OVERRIDE_HEADER_BODY",
+            tableName: "IPOverride",
+            value: "Import files or text with the new IP addresses for the servers in the Select location view.",
             comment: ""
         )
         let link = NSLocalizedString(
-            "ACCESS_METHOD_HEADER_LINK",
-            tableName: "APIAccess",
-            value: "About API access...",
+            "IP_OVERRIDE_HEADER_LINK",
+            tableName: "IPOverride",
+            value: "About Server IP override...",
             comment: ""
         )
 
diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift
index e7a36263cf3b..cf6ad054adc4 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift	
+++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift	
@@ -109,6 +109,7 @@ class AccountContentView: UIView {
             logoutButton,
             deleteButton,
         ])
+        arrangedSubviews.forEach { $0.isExclusiveTouch = true }
         let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
         stackView.axis = .vertical
         stackView.spacing = UIMetrics.padding16
diff --git a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift
index 55c8bccf1978..aa1fbe0502d3 100644
--- a/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift	
+++ b/ios/MullvadVPN/View controllers/Account/AccountDeviceRow.swift	
@@ -41,6 +41,7 @@ class AccountDeviceRow: UIView {
 
     private let infoButton: UIButton = {
         let button = IncreasedHitButton(type: .system)
+        button.isExclusiveTouch = true
         button.setAccessibilityIdentifier(.infoButton)
         button.tintColor = .white
         button.setImage(UIImage(named: "IconInfo"), for: .normal)
diff --git a/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift
index e1fe907b3e97..80cd434f99b2 100644
--- a/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift	
+++ b/ios/MullvadVPN/View controllers/Account/RestorePurchasesView.swift	
@@ -33,6 +33,7 @@ class RestorePurchasesView: UIView {
 
     private lazy var infoButton: UIButton = {
         let button = IncreasedHitButton(type: .custom)
+        button.isExclusiveTouch = true
         button.setImage(UIImage(resource: .iconInfo), for: .normal)
         button.tintColor = .white
         button.addTarget(self, action: #selector(didTapInfoButton), for: .touchUpInside)
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift
index 2fd62e6c41a9..e837f65cd423 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift	
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsCellFactory.swift	
@@ -30,12 +30,13 @@ final class SettingsCellFactory: @preconcurrency CellFactoryProtocol, Sendable {
     func makeCell(for item: SettingsDataSource.Item, indexPath: IndexPath) -> UITableViewCell {
         let cell: UITableViewCell
 
-        // Instantiate cell based on the specific item type
-        if item == .changelog {
-            cell = SettingsCell(style: .subtitle, reuseIdentifier: item.reuseIdentifier.rawValue)
-        } else {
-            cell = tableView.dequeueReusableCell(withIdentifier: item.reuseIdentifier.rawValue, for: indexPath)
-        }
+        cell = tableView
+            .dequeueReusableCell(
+                withIdentifier: item.reuseIdentifier.rawValue
+            ) ?? SettingsCell(
+                style: item.reuseIdentifier.cellStyle,
+                reuseIdentifier: item.reuseIdentifier.rawValue
+            )
 
         // Configure the cell with the common logic
         configureCell(cell, item: item, indexPath: indexPath)
diff --git a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift
index 8b992ad077cf..138fabf45070 100644
--- a/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift	
+++ b/ios/MullvadVPN/View controllers/Settings/SettingsDataSource.swift	
@@ -11,12 +11,20 @@ import UIKit
 
 final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource.Section, SettingsDataSource.Item>,
     UITableViewDelegate {
-    enum CellReuseIdentifiers: String, CaseIterable {
+    enum CellReuseIdentifier: String, CaseIterable {
         case basic
+        case changelog
 
         var reusableViewClass: AnyClass {
             SettingsCell.self
         }
+
+        var cellStyle: UITableViewCell.CellStyle {
+            switch self {
+            case .basic: .default
+            case .changelog: .subtitle
+            }
+        }
     }
 
     private enum HeaderFooterReuseIdentifier: String, CaseIterable, HeaderFooterIdentifierProtocol {
@@ -68,8 +76,11 @@ final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource
             }
         }
 
-        var reuseIdentifier: CellReuseIdentifiers {
-            .basic
+        var reuseIdentifier: CellReuseIdentifier {
+            switch self {
+            case .changelog: .changelog
+            default: .basic
+            }
         }
     }
 
@@ -132,13 +143,6 @@ final class SettingsDataSource: UITableViewDiffableDataSource<SettingsDataSource
     // MARK: - Private
 
     private func registerClasses() {
-        CellReuseIdentifiers.allCases.forEach { cellIdentifier in
-            tableView?.register(
-                cellIdentifier.reusableViewClass,
-                forCellReuseIdentifier: cellIdentifier.rawValue
-            )
-        }
-
         HeaderFooterReuseIdentifier.allCases.forEach { reuseIdentifier in
             tableView?.register(
                 reuseIdentifier.headerFooterClass,
diff --git a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift
index 3560a870d6fe..df39f51bee3e 100644
--- a/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift	
+++ b/ios/MullvadVPN/View controllers/Settings/SwiftUI components/SingleChoiceList.swift	
@@ -59,6 +59,8 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
     let tableAccessibilityIdentifier: String
     let itemDescription: (Value) -> String
     let customFieldMode: CustomFieldMode
+    // a latch to keep the custom field selected through changes of focus until the user taps elsewhere
+    @State var customFieldSelected = false
 
     /// The configuration for the field for a custom value row
     enum CustomFieldMode {
@@ -206,7 +208,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
     // Construct a literal row for a specific literal value
     private func literalRow(_ item: Value) -> some View {
         row(
-            isSelected: value.wrappedValue == item && !customValueIsFocused
+            isSelected: value.wrappedValue == item && !customFieldSelected
         ) {
             Text(verbatim: itemDescription(item))
             Spacer()
@@ -215,6 +217,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
             value.wrappedValue = item
             customValueIsFocused = false
             customValueInput = ""
+            customFieldSelected = false
         }
     }
 
@@ -229,7 +232,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
         fromValue: @escaping (Value) -> String?
     ) -> some View {
         row(
-            isSelected: value.wrappedValue == toValue(customValueInput) || customValueIsFocused
+            isSelected: value.wrappedValue == toValue(customValueInput) || customFieldSelected
         ) {
             Text(label)
             Spacer()
@@ -304,6 +307,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
             }
         }
         .onTapGesture {
+            customFieldSelected = true
             if let v = toValue(customValueInput) {
                 value.wrappedValue = v
             } else {
@@ -343,6 +347,7 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
                         switch opt.value {
                         case let .literal(v):
                             literalRow(v)
+                                .listRowSeparator(.hidden)
                         case let .custom(
                             label,
                             prompt,
@@ -360,8 +365,10 @@ struct SingleChoiceList<Value>: View where Value: Equatable {
                                 toValue: toValue,
                                 fromValue: fromValue
                             )
+                            .listRowSeparator(.hidden)
                             if let legend {
                                 subtitleRow(legend)
+                                    .listRowSeparator(.hidden)
                             }
                         }
                     }
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift
index 44c9c7d35707..00be5f526a39 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionView.swift	
@@ -38,6 +38,11 @@ struct ConnectionView: View {
         .background(BlurView(style: .dark))
         .cornerRadius(12)
         .padding(EdgeInsets(top: 16, leading: 16, bottom: 24, trailing: 16))
+        .onReceive(connectionViewModel.combinedState) { _ in
+            if !connectionViewModel.showsConnectionDetails {
+                isExpanded = false
+            }
+        }
     }
 }
 
diff --git a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift
index 4db9a75b9954..1540b79b321d 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/ConnectionView/ConnectionViewViewModel.swift	
@@ -30,7 +30,6 @@ class ConnectionViewViewModel: ObservableObject {
     @Published private(set) var tunnelStatus: TunnelStatus
     @Published var outgoingConnectionInfo: OutgoingConnectionInfo?
     @Published var showsActivityIndicator = false
-    @Published var showsConnectionDetails = false
 
     @Published var relayConstraints: RelayConstraints
     let destinationDescriber: DestinationDescribing
@@ -73,14 +72,15 @@ class ConnectionViewViewModel: ObservableObject {
 
     func update(tunnelStatus: TunnelStatus) {
         self.tunnelStatus = tunnelStatus
-        self.showsConnectionDetails = shouldShowConnectionDetails(tunnelStatus)
 
         if !tunnelIsConnected {
             outgoingConnectionInfo = nil
         }
     }
+}
 
-    private func shouldShowConnectionDetails(_ tunnelStatus: TunnelStatus) -> Bool {
+extension ConnectionViewViewModel {
+    var showsConnectionDetails: Bool {
         switch tunnelStatus.state {
         case .connecting, .reconnecting, .negotiatingEphemeralPeer,
              .connected, .pendingReconnect:
@@ -89,9 +89,7 @@ class ConnectionViewViewModel: ObservableObject {
             false
         }
     }
-}
 
-extension ConnectionViewViewModel {
     var textColorForSecureLabel: UIColor {
         switch tunnelStatus.state {
         case .connecting, .reconnecting, .waitingForConnectivity(.noConnection), .negotiatingEphemeralPeer,
diff --git a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift
index aa74ec5a6b76..b7cdeb454da7 100644
--- a/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift	
+++ b/ios/MullvadVPN/View controllers/Tunnel/TunnelViewController.swift	
@@ -177,13 +177,9 @@ class TunnelViewController: UIViewController, RootContainment {
 
         case let .connected(tunnelRelays, _, _):
             let center = tunnelRelays.exit.location.geoCoordinate
-            mapViewController.setCenter(center, animated: animated) {
-                // Connection can change during animation, so make sure we're still connected before adding marker.
-                if case .connected = self.tunnelState {
-                    self.mapViewController.addLocationMarker(coordinate: center)
-                    self.activityIndicator.stopAnimating()
-                }
-            }
+            mapViewController.setCenter(center, animated: animated)
+            activityIndicator.stopAnimating()
+            mapViewController.addLocationMarker(coordinate: center)
 
         case .pendingReconnect:
             activityIndicator.startAnimating()
diff --git a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
index 69f86e52caf6..fd27c4590004 100644
--- a/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
+++ b/ios/MullvadVPNUITests/Base/BaseUITestCase.swift
@@ -31,6 +31,13 @@ class BaseUITestCase: XCTestCase {
     /// Default relay to use in tests
     static let testsDefaultRelayName = "se-got-wg-001"
 
+    /// True when the current test case is capturing packets
+    private var currentTestCaseShouldCapturePackets = false
+
+    /// True when a packet capture session is active
+    private var packetCaptureSessionIsActive = false
+    private var packetCaptureSession: PacketCaptureSession?
+
     // swiftlint:disable force_cast
     let displayName = Bundle(for: BaseUITestCase.self)
         .infoDictionary?["DisplayName"] as! String
@@ -90,7 +97,7 @@ class BaseUITestCase: XCTestCase {
 
     /// Create temporary account without time. Will be created using partner API if token is configured, else falling back to app API
     func createTemporaryAccountWithoutTime() -> String {
-        if let partnerApiToken {
+        if partnerApiToken != nil {
             let partnerAPIClient = PartnerAPIClient()
             return partnerAPIClient.createAccount()
         } else {
@@ -136,7 +143,7 @@ class BaseUITestCase: XCTestCase {
         let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
 
         if springboard.buttons["Allow"].waitForExistence(timeout: Self.shortTimeout) {
-            let alertAllowButton = springboard.buttons.element(boundBy: 0)
+            let alertAllowButton = springboard.buttons["Allow"]
             if alertAllowButton.waitForExistence(timeout: Self.defaultTimeout) {
                 alertAllowButton.tap()
             }
@@ -160,6 +167,29 @@ class BaseUITestCase: XCTestCase {
         }
     }
 
+    /// Start packet capture for this test case
+    func startPacketCapture() {
+        currentTestCaseShouldCapturePackets = true
+        packetCaptureSessionIsActive = true
+        let packetCaptureClient = PacketCaptureClient()
+        packetCaptureSession = packetCaptureClient.startCapture()
+    }
+
+    /// Stop the current packet capture and return captured traffic
+    func stopPacketCapture() -> [Stream] {
+        packetCaptureSessionIsActive = false
+        guard let packetCaptureSession else {
+            XCTFail("Trying to stop capture when there is no active capture")
+            return []
+        }
+
+        let packetCaptureAPIClient = PacketCaptureClient()
+        packetCaptureAPIClient.stopCapture(session: packetCaptureSession)
+        let capturedData = packetCaptureAPIClient.getParsedCaptureObjects(session: packetCaptureSession)
+
+        return capturedData
+    }
+
     // MARK: - Setup & teardown
 
     /// Override this class function to change the uninstall behaviour in suite level teardown
@@ -176,12 +206,42 @@ class BaseUITestCase: XCTestCase {
 
     /// Test level setup
     override func setUp() {
+        currentTestCaseShouldCapturePackets = false // Reset for each test case run
         continueAfterFailure = false
         app.launch()
     }
 
     /// Test level teardown
     override func tearDown() {
+        if currentTestCaseShouldCapturePackets {
+            guard let packetCaptureSession = packetCaptureSession else {
+                XCTFail("Packet capture session unexpectedly not set up")
+                return
+            }
+
+            let packetCaptureClient = PacketCaptureClient()
+
+            // If there's a an active session due to cancelled/failed test run make sure to end it
+            if packetCaptureSessionIsActive {
+                packetCaptureSessionIsActive = false
+                packetCaptureClient.stopCapture(session: packetCaptureSession)
+            }
+
+            let pcapFileContents = packetCaptureClient.getPCAP(session: packetCaptureSession)
+            let parsedCapture = packetCaptureClient.getParsedCapture(session: packetCaptureSession)
+            self.packetCaptureSession = nil
+
+            let pcapAttachment = XCTAttachment(data: pcapFileContents)
+            pcapAttachment.name = self.name + ".pcap"
+            pcapAttachment.lifetime = .keepAlways
+            self.add(pcapAttachment)
+
+            let jsonAttachment = XCTAttachment(data: parsedCapture)
+            jsonAttachment.name = self.name + ".json"
+            jsonAttachment.lifetime = .keepAlways
+            self.add(jsonAttachment)
+        }
+
         app.terminate()
 
         if let testRun = self.testRun, testRun.failureCount > 0, attachAppLogsOnFailure == true {
@@ -341,4 +401,5 @@ class BaseUITestCase: XCTestCase {
             XCTFail("Failed to find 'Delete'")
         }
     }
+    // swiftlint:disable:next file_length
 }
diff --git a/ios/MullvadVPNUITests/ConnectivityTests.swift b/ios/MullvadVPNUITests/ConnectivityTests.swift
index 1cafff9ff30c..bcb683583f37 100644
--- a/ios/MullvadVPNUITests/ConnectivityTests.swift
+++ b/ios/MullvadVPNUITests/ConnectivityTests.swift
@@ -11,7 +11,7 @@ import Network
 import XCTest
 
 class ConnectivityTests: LoggedOutUITestCase {
-    let firewallAPIClient = FirewallAPIClient()
+    let firewallAPIClient = FirewallClient()
 
     /// Verifies that the app still functions when API has been blocked
     func testAPIConnectionViaBridges() throws {
diff --git a/ios/MullvadVPNUITests/Info.plist b/ios/MullvadVPNUITests/Info.plist
index 229e9483278b..2bdf415ada1c 100644
--- a/ios/MullvadVPNUITests/Info.plist
+++ b/ios/MullvadVPNUITests/Info.plist
@@ -24,10 +24,14 @@
 	<string>$(IOS_DEVICE_PIN_CODE)</string>
 	<key>NoTimeAccountNumber</key>
 	<string>$(NO_TIME_ACCOUNT_NUMBER)</string>
+	<key>PacketCaptureAPIBaseURL</key>
+	<string>$(PACKET_CAPTURE_BASE_URL)</string>
 	<key>PartnerApiToken</key>
 	<string>$(PARTNER_API_TOKEN)</string>
 	<key>ShouldBeReachableDomain</key>
 	<string>$(SHOULD_BE_REACHABLE_DOMAIN)</string>
+	<key>ShouldBeReachableIPAddress</key>
+	<string>$(SHOULD_BE_REACHABLE_IP_ADDRESS)</string>
 	<key>TestDeviceIdentifier</key>
 	<string>$(TEST_DEVICE_IDENTIFIER_UUID)</string>
 	<key>TestDeviceIsIPad</key>
diff --git a/ios/MullvadVPNUITests/LeakTests.swift b/ios/MullvadVPNUITests/LeakTests.swift
new file mode 100644
index 000000000000..17f75683b9b1
--- /dev/null
+++ b/ios/MullvadVPNUITests/LeakTests.swift
@@ -0,0 +1,104 @@
+//
+//  LeakTests.swift
+//  MullvadVPNUITests
+//
+//  Created by Niklas Berglund on 2024-05-31.
+//  Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import XCTest
+
+class LeakTests: LoggedInWithTimeUITestCase {
+    static let capturedStreamStartTimestamp: Double = 8
+    static let capturedStreamEndTimestamp: Double = 3
+
+    override func tearDown() {
+        FirewallClient().removeRules()
+        super.tearDown()
+    }
+
+    /// Send UDP traffic to a host, connect to relay and make sure - while connected to relay -
+    /// that no leaked traffic went directly to the host
+    func testConnectionStartedBeforeTunnelShouldNotLeakOutside() throws {
+        let skipReason = """
+        Connections started before the packet tunnel will leak as long as
+        includeAllNetworks is not set to true when starting the tunnel.
+        """
+        try XCTSkipIf(true, skipReason)
+        let targetIPAddress = Networking.getAlwaysReachableIPAddress()
+        startPacketCapture()
+        let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
+        trafficGenerator.startGeneratingUDPTraffic(interval: 1.0)
+
+        TunnelControlPage(app)
+            .tapConnectButton()
+
+        allowAddVPNConfigurationsIfAsked()
+
+        TunnelControlPage(app)
+            .waitForConnectedLabel()
+
+        // Keep the tunnel connection for a while
+        RunLoop.current.run(until: .now + 30)
+
+        TunnelControlPage(app)
+            .tapDisconnectButton()
+
+        trafficGenerator.stopGeneratingUDPTraffic()
+
+        var capturedStreams = stopPacketCapture()
+        // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
+        capturedStreams = PacketCaptureClient.trimPackets(
+            streams: capturedStreams,
+            secondsStart: Self.capturedStreamStartTimestamp,
+            secondsEnd: Self.capturedStreamEndTimestamp
+        )
+        LeakCheck.assertNoLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
+    }
+
+    /// Send UDP traffic to a host, connect to relay and then disconnect to intentionally leak traffic and make sure that the test catches the leak
+    func testTrafficCapturedOutsideOfTunnelShouldLeak() throws {
+        let targetIPAddress = Networking.getAlwaysReachableIPAddress()
+        startPacketCapture()
+        let trafficGenerator = TrafficGenerator(destinationHost: targetIPAddress, port: 80)
+        trafficGenerator.startGeneratingUDPTraffic(interval: 1.0)
+
+        TunnelControlPage(app)
+            .tapConnectButton()
+
+        allowAddVPNConfigurationsIfAsked()
+
+        TunnelControlPage(app)
+            .waitForConnectedLabel()
+
+        RunLoop.current.run(until: .now + 2)
+
+        TunnelControlPage(app)
+            .tapDisconnectButton()
+
+        // Give it some time to generate traffic outside of tunnel
+        RunLoop.current.run(until: .now + 5)
+
+        TunnelControlPage(app)
+            .tapConnectButton()
+
+        // Keep the tunnel connection for a while
+        RunLoop.current.run(until: .now + 5)
+
+        TunnelControlPage(app)
+            .tapDisconnectButton()
+
+        // Keep the capture open for a while
+        RunLoop.current.run(until: .now + 15)
+        trafficGenerator.stopGeneratingUDPTraffic()
+
+        var capturedStreams = stopPacketCapture()
+        // For now cut the beginning and and end of the stream to trim out the part where the tunnel connection was not up
+        capturedStreams = PacketCaptureClient.trimPackets(
+            streams: capturedStreams,
+            secondsStart: Self.capturedStreamStartTimestamp,
+            secondsEnd: Self.capturedStreamEndTimestamp
+        )
+        LeakCheck.assertLeaks(streams: capturedStreams, rules: [NoTrafficToHostLeakRule(host: targetIPAddress)])
+    }
+}
diff --git a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift b/ios/MullvadVPNUITests/Networking/FirewallClient.swift
similarity index 68%
rename from ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift
rename to ios/MullvadVPNUITests/Networking/FirewallClient.swift
index 917ac13fd71d..35d8d25968cc 100644
--- a/ios/MullvadVPNUITests/Networking/FirewallAPIClient.swift
+++ b/ios/MullvadVPNUITests/Networking/FirewallClient.swift
@@ -11,20 +11,16 @@ import SystemConfiguration
 import UIKit
 import XCTest
 
-class FirewallAPIClient {
+class FirewallClient: TestRouterAPIClient {
     // swiftlint:disable force_cast
-    let baseURL = URL(
-        string:
-        Bundle(for: FirewallAPIClient.self).infoDictionary?["FirewallApiBaseURL"] as! String
-    )!
-    let testDeviceIdentifier = Bundle(for: FirewallAPIClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
+    let testDeviceIdentifier = Bundle(for: FirewallClient.self).infoDictionary?["TestDeviceIdentifier"] as! String
     // swiftlint:enable force_cast
 
     lazy var sessionIdentifier = "urn:uuid:" + testDeviceIdentifier
 
     /// Create a new rule associated to the device under test
     public func createRule(_ firewallRule: FirewallRule) {
-        let createRuleURL = baseURL.appendingPathComponent("rule")
+        let createRuleURL = TestRouterAPIClient.baseURL.appendingPathComponent("rule")
 
         var request = URLRequest(url: createRuleURL)
         request.httpMethod = "POST"
@@ -64,7 +60,9 @@ class FirewallAPIClient {
             } else {
                 if let response = requestResponse as? HTTPURLResponse {
                     if response.statusCode != 201 {
-                        XCTFail("Failed to create firewall rule - unexpected server response")
+                        XCTFail(
+                            "Failed to create firewall rule - unexpected response status code \(response.statusCode)"
+                        )
                     }
                 }
 
@@ -77,43 +75,9 @@ class FirewallAPIClient {
         }
     }
 
-    /// Gets the IP address of the device under test
-    public func getDeviceIPAddress() throws -> String {
-        let deviceIPURL = baseURL.appendingPathComponent("own-ip")
-        let request = URLRequest(url: deviceIPURL)
-        let completionHandlerInvokedExpectation = XCTestExpectation(
-            description: "Completion handler for the request is invoked"
-        )
-        var deviceIPAddress = ""
-        var requestError: Error?
-
-        let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
-            defer { completionHandlerInvokedExpectation.fulfill() }
-            guard let data else {
-                requestError = NetworkingError.internalError(reason: "Could not get device IP")
-                return
-            }
-
-            deviceIPAddress = String(data: data, encoding: .utf8)!
-        }
-
-        dataTask.resume()
-
-        let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30)
-        if waitResult != .completed {
-            XCTFail("Failed to get device IP address - timeout")
-        }
-
-        if let requestError {
-            throw requestError
-        }
-
-        return deviceIPAddress
-    }
-
     /// Remove all firewall rules associated to this device under test
     public func removeRules() {
-        let removeRulesURL = baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")
+        let removeRulesURL = TestRouterAPIClient.baseURL.appendingPathComponent("remove-rules/\(sessionIdentifier)")
 
         var request = URLRequest(url: removeRulesURL)
         request.httpMethod = "DELETE"
diff --git a/ios/MullvadVPNUITests/Networking/FirewallRule.swift b/ios/MullvadVPNUITests/Networking/FirewallRule.swift
index ed5cf01bc741..51d79c1931af 100644
--- a/ios/MullvadVPNUITests/Networking/FirewallRule.swift
+++ b/ios/MullvadVPNUITests/Networking/FirewallRule.swift
@@ -9,22 +9,16 @@
 import Foundation
 import XCTest
 
-enum NetworkingProtocol: String {
-    case TCP = "tcp"
-    case UDP = "udp"
-    case ICMP = "icmp"
-}
-
 struct FirewallRule {
     let fromIPAddress: String
     let toIPAddress: String
-    let protocols: [NetworkingProtocol]
+    let protocols: [NetworkTransportProtocol]
 
     /// - Parameters:
     ///     - fromIPAddress: Block traffic originating from this source IP address.
     ///     - toIPAddress: Block traffic to this destination IP address.
     ///     - protocols: Protocols which should be blocked. If none is specified all will be blocked.
-    private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkingProtocol]) {
+    private init(fromIPAddress: String, toIPAddress: String, protocols: [NetworkTransportProtocol]) {
         self.fromIPAddress = fromIPAddress
         self.toIPAddress = toIPAddress
         self.protocols = protocols
@@ -36,7 +30,7 @@ struct FirewallRule {
 
     /// Make a firewall rule blocking API access for the current device under test
     public static func makeBlockAPIAccessFirewallRule() throws -> FirewallRule {
-        let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
+        let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
         let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress()
         return FirewallRule(
             fromIPAddress: deviceIPAddress,
@@ -46,7 +40,7 @@ struct FirewallRule {
     }
 
     public static func makeBlockAllTrafficRule(toIPAddress: String) throws -> FirewallRule {
-        let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
+        let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
 
         return FirewallRule(
             fromIPAddress: deviceIPAddress,
@@ -56,7 +50,7 @@ struct FirewallRule {
     }
 
     public static func makeBlockUDPTrafficRule(toIPAddress: String) throws -> FirewallRule {
-        let deviceIPAddress = try FirewallAPIClient().getDeviceIPAddress()
+        let deviceIPAddress = try FirewallClient().getDeviceIPAddress()
 
         return FirewallRule(
             fromIPAddress: deviceIPAddress,
diff --git a/ios/MullvadVPNUITests/Networking/LeakCheck.swift b/ios/MullvadVPNUITests/Networking/LeakCheck.swift
new file mode 100644
index 000000000000..2a9319976ff0
--- /dev/null
+++ b/ios/MullvadVPNUITests/Networking/LeakCheck.swift
@@ -0,0 +1,41 @@
+//
+//  LeakCheck.swift
+//  MullvadVPN
+//
+//  Created by Niklas Berglund on 2024-12-16.
+//  Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import XCTest
+
+class LeakCheck {
+    static func assertNoLeaks(streams: [Stream], rules: [NoTrafficToHostLeakRule]) {
+        XCTAssertFalse(streams.isEmpty, "No streams to leak check")
+        XCTAssertFalse(rules.isEmpty, "No leak rules to check")
+
+        for rule in rules where rule.isViolated(streams: streams) {
+            XCTFail("Leaked traffic destined to \(rule.host) outside of the tunnel connection")
+        }
+    }
+
+    static func assertLeaks(streams: [Stream], rules: [NoTrafficToHostLeakRule]) {
+        XCTAssertFalse(streams.isEmpty, "No streams to leak check")
+        XCTAssertFalse(rules.isEmpty, "No leak rules to check")
+
+        for rule in rules where rule.isViolated(streams: streams) == false {
+            XCTFail("Expected to leak traffic to \(rule.host) outside of tunnel")
+        }
+    }
+}
+
+class NoTrafficToHostLeakRule {
+    let host: String
+
+    init(host: String) {
+        self.host = host
+    }
+
+    func isViolated(streams: [Stream]) -> Bool {
+        streams.filter { $0.destinationAddress == host }.isEmpty == false
+    }
+}
diff --git a/ios/MullvadVPNUITests/Networking/Networking.swift b/ios/MullvadVPNUITests/Networking/Networking.swift
index c260906670e6..27a47f716d8e 100644
--- a/ios/MullvadVPNUITests/Networking/Networking.swift
+++ b/ios/MullvadVPNUITests/Networking/Networking.swift
@@ -10,6 +10,12 @@ import Foundation
 import Network
 import XCTest
 
+enum NetworkTransportProtocol: String, Codable {
+    case TCP = "tcp"
+    case UDP = "udp"
+    case ICMP = "icmp"
+}
+
 enum NetworkingError: Error {
     case notConfiguredError
     case internalError(reason: String)
@@ -32,16 +38,6 @@ class Networking {
         return adServingDomain
     }
 
-    /// Get configured domain to use for Internet connectivity checks
-    private static func getAlwaysReachableDomain() throws -> String {
-        guard let shouldBeReachableDomain = Bundle(for: Networking.self)
-            .infoDictionary?["ShouldBeReachableDomain"] as? String else {
-            throw NetworkingError.notConfiguredError
-        }
-
-        return shouldBeReachableDomain
-    }
-
     /// Check whether host and port is reachable by attempting to connect a socket
     private static func canConnectSocket(host: String, port: String) throws -> Bool {
         let socketHost = NWEndpoint.Host(host)
@@ -79,6 +75,26 @@ class Networking {
         return true
     }
 
+    /// Get configured domain to use for Internet connectivity checks
+    public static func getAlwaysReachableDomain() throws -> String {
+        guard let shouldBeReachableDomain = Bundle(for: Networking.self)
+            .infoDictionary?["ShouldBeReachableDomain"] as? String else {
+            throw NetworkingError.notConfiguredError
+        }
+
+        return shouldBeReachableDomain
+    }
+
+    public static func getAlwaysReachableIPAddress() -> String {
+        guard let shouldBeReachableIPAddress = Bundle(for: Networking.self)
+            .infoDictionary?["ShouldBeReachableIPAddress"] as? String else {
+            XCTFail("Should be reachable IP address not configured")
+            return String()
+        }
+
+        return shouldBeReachableIPAddress
+    }
+
     /// Verify API can be accessed by attempting to connect a socket to the configured API host and port
     public static func verifyCanAccessAPI() throws {
         let apiIPAddress = try MullvadAPIWrapper.getAPIIPAddress()
diff --git a/ios/MullvadVPNUITests/Networking/PacketCapture.swift b/ios/MullvadVPNUITests/Networking/PacketCapture.swift
new file mode 100644
index 000000000000..d47e8433bacc
--- /dev/null
+++ b/ios/MullvadVPNUITests/Networking/PacketCapture.swift
@@ -0,0 +1,341 @@
+//
+//  PacketCapture.swift
+//  MullvadVPNUITests
+//
+//  Created by Niklas Berglund on 2024-04-30.
+//  Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import XCTest
+
+struct PacketCaptureSession {
+    var identifier: String
+
+    init(identifier: String = UUID().uuidString) {
+        self.identifier = identifier
+
+        print("Current Packet Capture session identifier is: \(identifier)")
+    }
+}
+
+/// Represents a stream in packet capture
+class Stream: Codable, Equatable {
+    static func == (lhs: Stream, rhs: Stream) -> Bool {
+        return lhs.sourceAddress == rhs.sourceAddress &&
+            lhs.destinationAddress == rhs.destinationAddress &&
+            lhs.flowID == rhs.flowID &&
+            lhs.transportProtocol == rhs.transportProtocol
+    }
+
+    let sourceAddress: String
+    let sourcePort: Int
+    let destinationAddress: String
+    let destinationPort: Int
+    let flowID: String?
+    let transportProtocol: NetworkTransportProtocol
+    var packets: [Packet] {
+        didSet {
+            determineDateInterval()
+        }
+    }
+
+    /// Date interval from first to last packet of this stream
+    var dateInterval: DateInterval
+
+    /// Date interval from first to last tx(sent from test device) packet of this stream
+    var txInterval: DateInterval?
+
+    /// Date interval from frist to last rx(sent to test device) packet of this stream
+    var rxInterval: DateInterval?
+
+    enum CodingKeys: String, CodingKey {
+        case sourceAddress = "peer_addr"
+        case destinationAddress = "other_addr"
+        case flowID = "flow_id"
+        case transportProtocol = "transport_protocol"
+        case packets
+    }
+
+    required init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        self.flowID = try container.decodeIfPresent(String.self, forKey: .flowID)
+        self.transportProtocol = try container.decode(NetworkTransportProtocol.self, forKey: .transportProtocol)
+        self.packets = try container.decode([Packet].self, forKey: .packets)
+        dateInterval = DateInterval()
+
+        // Separate source address and port
+        let sourceValue = try container.decode(String.self, forKey: .sourceAddress)
+        let sourceSplit = sourceValue.components(separatedBy: ":")
+        self.sourceAddress = try XCTUnwrap(sourceSplit.first)
+        self.sourcePort = try XCTUnwrap(Int(try XCTUnwrap(sourceSplit.last)))
+
+        // Separate destination address and port
+        let destinationValue = try container.decode(String.self, forKey: .destinationAddress)
+        let destinationSplit = destinationValue.components(separatedBy: ":")
+        self.destinationAddress = try XCTUnwrap(destinationSplit.first)
+        self.destinationPort = try XCTUnwrap(Int(try XCTUnwrap(destinationSplit.last)))
+
+        // Set date interval based on packets' time window
+        determineDateInterval()
+    }
+
+    /// Determine the stream's date interval from the time between first to the last packet
+    private func determineDateInterval() {
+        guard packets.isEmpty == false else {
+            XCTFail("Stream unexpectedly have no packets")
+            return
+        }
+
+        // Identify first tx and rx packets to set as initial values
+        let txPackets = packets.filter { $0.fromPeer == true }.sorted { $0.date < $1.date }
+        let rxPackets = packets.filter { $0.fromPeer == false }.sorted { $0.date < $1.date }
+        let allPackets = packets.sorted { $0.date < $1.date }
+
+        if let firstTxPacket = txPackets.first, let lastTxPacket = txPackets.last {
+            txInterval = DateInterval(start: firstTxPacket.date, end: lastTxPacket.date)
+        }
+
+        if let firstRxPacket = rxPackets.first, let lastRxPacket = rxPackets.last {
+            rxInterval = DateInterval(start: firstRxPacket.date, end: lastRxPacket.date)
+        }
+
+        if let firstPacket = allPackets.first, let lastPacket = allPackets.last {
+            dateInterval = DateInterval(start: firstPacket.date, end: lastPacket.date)
+        }
+    }
+}
+
+/// Represents a packet in packet capture
+class Packet: Codable, Equatable {
+    /// True when packet is sent from device under test, false if from another host
+    public let fromPeer: Bool
+
+    /// Timestamp in microseconds
+    private var timestamp: Int64
+
+    public var date: Date
+
+    enum CodingKeys: String, CodingKey {
+        case fromPeer = "from_peer"
+        case timestamp
+    }
+
+    required init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        fromPeer = try container.decode(Bool.self, forKey: .fromPeer)
+        timestamp = try container.decode(Int64.self, forKey: .timestamp) / 1000000
+        date = Date(timeIntervalSince1970: TimeInterval(timestamp))
+    }
+
+    static func == (lhs: Packet, rhs: Packet) -> Bool {
+        return lhs.fromPeer == rhs.fromPeer &&
+            lhs.timestamp == rhs.timestamp &&
+            lhs.date == rhs.date
+    }
+}
+
+class PacketCaptureClient: TestRouterAPIClient {
+    /// Start a new capture session
+    func startCapture() -> PacketCaptureSession {
+        let session = PacketCaptureSession()
+
+        let jsonDictionary = [
+            "label": session.identifier,
+        ]
+
+        _ = sendRequest(
+            httpMethod: "POST",
+            endpoint: "capture",
+            contentType: "application/json",
+            jsonData: jsonDictionary
+        )
+
+        return session
+    }
+
+    /// Stop capture for session
+    func stopCapture(session: PacketCaptureSession) {
+        _ = sendJSONRequest(httpMethod: "POST", endpoint: "stop-capture/\(session.identifier)", jsonData: nil)
+    }
+
+    /// Cut specified number of seconds from the beginning and end of data capture
+    static func trimPackets(streams: [Stream], secondsStart: Double, secondsEnd: Double) -> [Stream] {
+        var collectionStartDate: Date?
+        var collectionEndDate: Date?
+
+        XCTAssertTrue(streams.count >= 1, "Captured streams are empty, expected at least 1")
+
+        for stream in streams {
+            if collectionStartDate != nil {
+                collectionStartDate = min(collectionStartDate!, stream.dateInterval.start)
+            } else {
+                collectionStartDate = stream.dateInterval.start
+            }
+
+            if collectionEndDate != nil {
+                collectionEndDate = max(collectionEndDate!, stream.dateInterval.end)
+            } else {
+                collectionEndDate = stream.dateInterval.end
+            }
+        }
+
+        let cutStartDate = collectionStartDate!.addingTimeInterval(secondsStart)
+        let cutEndDate = collectionEndDate!.addingTimeInterval(-secondsEnd)
+
+        var trimmedStreams: [Stream] = []
+        for stream in streams {
+            let packetsWithinTimeframe = stream.packets.filter { packet in
+                return packet.date >= cutStartDate && packet.date <= cutEndDate
+            }
+
+            if packetsWithinTimeframe.isEmpty == false {
+                stream.packets = packetsWithinTimeframe
+                trimmedStreams.append(stream)
+            }
+        }
+
+        return trimmedStreams
+    }
+
+    /// Get captured traffic from this session parsed to objects
+    func getParsedCaptureObjects(session: PacketCaptureSession) -> [Stream] {
+        let parsedData = getParsedCapture(session: session)
+        let decoder = JSONDecoder()
+
+        do {
+            let streams = try decoder.decode([Stream].self, from: parsedData)
+            return streams
+        } catch {
+            XCTFail("Failed to decode parsed capture")
+            return []
+        }
+    }
+
+    /// Get captured traffic from this session parsed to JSON
+    func getParsedCapture(session: PacketCaptureSession) -> Data {
+        var deviceIPAddress: String
+
+        do {
+            deviceIPAddress = try getDeviceIPAddress()
+        } catch {
+            XCTFail("Failed to get device IP address")
+            return Data()
+        }
+
+        let responseData = sendJSONRequest(
+            httpMethod: "PUT",
+            endpoint: "parse-capture/\(session.identifier)",
+            jsonData: [deviceIPAddress]
+        )
+
+        return responseData
+    }
+
+    /// Get PCAP file contents for the capture of this session
+    func getPCAP(session: PacketCaptureSession) -> Data {
+        let response = sendPCAPRequest(httpMethod: "GET", endpoint: "last-capture/\(session.identifier)", jsonData: nil)
+        return response
+    }
+
+    private func sendJSONRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data {
+        let responseData = sendRequest(
+            httpMethod: httpMethod,
+            endpoint: endpoint,
+            contentType: "application/json",
+            jsonData: jsonData
+        )
+
+        guard let responseData else {
+            XCTFail("Unexpectedly didn't get any data from JSON request")
+            return Data()
+        }
+
+        return responseData
+    }
+
+    private func sendPCAPRequest(httpMethod: String, endpoint: String, jsonData: Any?) -> Data {
+        let responseData = sendRequest(
+            httpMethod: httpMethod,
+            endpoint: endpoint,
+            contentType: "application/pcap",
+            jsonData: jsonData
+        )
+
+        guard let responseData else {
+            XCTFail("Unexpectedly didn't get any data from response")
+            return Data()
+        }
+
+        XCTAssertFalse(responseData.isEmpty, "PCAP response data should not be empty")
+
+        return responseData
+    }
+
+    private func sendRequest(httpMethod: String, endpoint: String, contentType: String?, jsonData: Any?) -> Data? {
+        let url = TestRouterAPIClient.baseURL.appendingPathComponent(endpoint)
+
+        var request = URLRequest(url: url)
+        request.httpMethod = httpMethod
+
+        if let contentType {
+            request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+        }
+
+        if let jsonData = jsonData {
+            do {
+                request.httpBody = try JSONSerialization.data(withJSONObject: jsonData)
+            } catch {
+                XCTFail("Failed to serialize JSON data")
+            }
+        }
+
+        var requestResponse: URLResponse?
+        var requestError: Error?
+        var responseData: Data?
+
+        let completionHandlerInvokedExpectation = XCTestExpectation(
+            description: "Completion handler for the request is invoked"
+        )
+
+        let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
+            requestResponse = response
+            requestError = error
+
+            guard let data = data,
+                  let response = response as? HTTPURLResponse,
+                  error == nil else {
+                XCTFail("Error: \(error?.localizedDescription ?? "Unknown error")")
+                return
+            }
+
+            if 200 ... 204 ~= response.statusCode && error == nil {
+                responseData = data
+            } else {
+                XCTFail("Request failed")
+            }
+
+            completionHandlerInvokedExpectation.fulfill()
+        }
+
+        dataTask.resume()
+
+        let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30)
+
+        if waitResult != .completed {
+            XCTFail("Failed to send packet capture API request - timeout")
+        } else {
+            if let response = requestResponse as? HTTPURLResponse {
+                if (200 ... 201 ~= response.statusCode) == false {
+                    XCTFail("Packet capture API request failed - unexpected server response")
+                }
+            }
+
+            if let error = requestError {
+                XCTFail("Packet capture API request failed - encountered error \(error.localizedDescription)")
+            }
+        }
+
+        return responseData
+    }
+}
diff --git a/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift
new file mode 100644
index 000000000000..0d9c9da2587c
--- /dev/null
+++ b/ios/MullvadVPNUITests/Networking/TestRouterAPIClient.swift
@@ -0,0 +1,48 @@
+//
+//  TestRouterAPIClient.swift
+//  MullvadVPN
+//
+//  Created by Niklas Berglund on 2024-12-18.
+//  Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import XCTest
+
+class TestRouterAPIClient {
+    // swiftlint:disable:next force_cast
+    static let baseURL = URL(string: Bundle(for: FirewallClient.self).infoDictionary?["FirewallApiBaseURL"] as! String)!
+
+    /// Gets the IP address of the device under test
+    public func getDeviceIPAddress() throws -> String {
+        let deviceIPURL = TestRouterAPIClient.baseURL.appendingPathComponent("own-ip")
+        let request = URLRequest(url: deviceIPURL)
+        let completionHandlerInvokedExpectation = XCTestExpectation(
+            description: "Completion handler for the request is invoked"
+        )
+        var deviceIPAddress = ""
+        var requestError: Error?
+
+        let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
+            defer { completionHandlerInvokedExpectation.fulfill() }
+            guard let data else {
+                requestError = NetworkingError.internalError(reason: "Could not get device IP")
+                return
+            }
+
+            deviceIPAddress = String(data: data, encoding: .utf8)!
+        }
+
+        dataTask.resume()
+
+        let waitResult = XCTWaiter.wait(for: [completionHandlerInvokedExpectation], timeout: 30)
+        if waitResult != .completed {
+            XCTFail("Failed to get device IP address - timeout")
+        }
+
+        if let requestError {
+            throw requestError
+        }
+
+        return deviceIPAddress
+    }
+}
diff --git a/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift
new file mode 100644
index 000000000000..8a911837f5bf
--- /dev/null
+++ b/ios/MullvadVPNUITests/Networking/TrafficGenerator.swift
@@ -0,0 +1,178 @@
+//
+//  TrafficGenerator.swift
+//  MullvadVPNUITests
+//
+//  Created by Niklas Berglund on 2024-06-25.
+//  Copyright © 2024 Mullvad VPN AB. All rights reserved.
+//
+
+import Network
+import XCTest
+
+class TrafficGenerator {
+    let destinationHost: String
+    let port: Int
+    var connection: NWConnection
+    let dispatchQueue = DispatchQueue(label: "TrafficGeneratorDispatchQueue")
+    var sendDataTimer: DispatchSourceTimer
+
+    init(destinationHost: String, port: Int) {
+        self.destinationHost = destinationHost
+        self.port = port
+
+        sendDataTimer = DispatchSource.makeTimerSource(queue: dispatchQueue)
+        let params = NWParameters.udp
+        connection = NWConnection(
+            host: NWEndpoint.Host(destinationHost),
+            port: NWEndpoint.Port(integerLiteral: UInt16(port)),
+            using: params
+        )
+        setupConnection()
+        setupOtherHandlers()
+    }
+
+    func reconnect() {
+        print("Attempting to reconnect")
+        connection.forceCancel()
+
+        connection = createConnection()
+        setupConnection()
+        setupOtherHandlers()
+    }
+
+    func createConnection() -> NWConnection {
+        let params = NWParameters.udp
+        return NWConnection(
+            host: NWEndpoint.Host(destinationHost),
+            port: NWEndpoint.Port(integerLiteral: UInt16(port)),
+            using: params
+        )
+    }
+
+    func setupOtherHandlers() {
+        connection.pathUpdateHandler = { newPath in
+            let availableInterfaces = newPath.availableInterfaces.map { $0.customDebugDescription }
+            let availableGateways = newPath.gateways.map { $0.customDebugDescription }
+
+            print("New interfaces available: \(availableInterfaces)")
+            print("New gateways available: \(availableGateways)")
+        }
+
+        connection.viabilityUpdateHandler = { newViability in
+            print("Connection is viable: \(newViability)")
+        }
+
+        connection.betterPathUpdateHandler = { betterPathAvailable in
+            print("A better path is available: \(betterPathAvailable)")
+        }
+    }
+
+    func setupConnection() {
+        print("Setting up connection...")
+        let doneAttemptingConnectExpecation = XCTestExpectation(description: "Done attemping to connect")
+
+        connection.stateUpdateHandler = { state in
+            switch state {
+            case .ready:
+                print("Ready")
+                self.sendDataTimer.resume()
+                doneAttemptingConnectExpecation.fulfill()
+            case let .failed(error):
+                print("Failed to connect: \(error)")
+                self.sendDataTimer.cancel()
+                self.reconnect()
+            case .preparing:
+                print("Preparing connection...")
+            case .setup:
+                print("Setting upp connection...")
+            case let .waiting(error):
+                print("Waiting to connect: \(error)")
+            case .cancelled:
+                self.sendDataTimer.suspend()
+                print("Cancelled connection")
+                self.reconnect()
+            default:
+                break
+            }
+        }
+        connection.start(queue: dispatchQueue)
+
+        XCTWaiter().wait(for: [doneAttemptingConnectExpecation], timeout: 10.0)
+    }
+
+    public func startGeneratingUDPTraffic(interval: TimeInterval) {
+        sendDataTimer.schedule(deadline: .now(), repeating: interval)
+
+        sendDataTimer.setEventHandler {
+            let data = Data("dGhpcyBpcyBqdXN0IHNvbWUgZHVtbXkgZGF0YSB0aGlzIGlzIGp1c3Qgc29tZSBkdW".utf8)
+
+            print("Attempting to send data...")
+
+            if self.connection.state != .ready {
+                print("Not connected, won't send data")
+            } else {
+                self.connection.send(content: data, completion: .contentProcessed { error in
+                    if let error = error {
+                        print("Failed to send data: \(error)")
+                    } else {
+                        print("Data sent")
+                    }
+                })
+            }
+        }
+
+        sendDataTimer.activate()
+    }
+
+    public func stopGeneratingUDPTraffic() {
+        sendDataTimer.cancel()
+    }
+}
+
+extension NWInterface {
+    var customDebugDescription: String {
+        "type: \(type) name: \(self.name) index: \(index)"
+    }
+}
+
+extension NWInterface.InterfaceType: @retroactive CustomDebugStringConvertible {
+    public var debugDescription: String {
+        switch self {
+        case .cellular: "Cellular"
+        case .loopback: "Loopback"
+        case .other: "Other"
+        case .wifi: "Wifi"
+        case .wiredEthernet: "Wired Ethernet"
+        @unknown default: "Unknown interface type"
+        }
+    }
+}
+
+extension NWEndpoint {
+    var customDebugDescription: String {
+        switch self {
+        case let .hostPort(host, port): "host: \(host.customDebugDescription) port: \(port)"
+        case let .opaque(endpoint): "opaque: \(endpoint.description)"
+        case let .url(url): "url: \(url)"
+        case let .service(
+            name,
+            type,
+            domain,
+            interface
+        ): "service named:\(name), type:\(type), domain:\(domain), interface:\(interface?.customDebugDescription ?? "[No interface]")"
+        case let .unix(path): "unix: \(path)"
+        @unknown default: "Unknown NWEndpoint type"
+        }
+    }
+}
+
+extension NWEndpoint.Host {
+    var customDebugDescription: String {
+        switch self {
+        case let .ipv4(IPv4Address): "IPv4: \(IPv4Address)"
+        case let .ipv6(IPv6Address): "IPv6: \(IPv6Address)"
+        case let .name(name, interface): "named: \(name), \(interface?.customDebugDescription ?? "[No interface]")"
+        @unknown default: "Unknown host"
+        }
+    }
+}
diff --git a/ios/MullvadVPNUITests/RelayTests.swift b/ios/MullvadVPNUITests/RelayTests.swift
index 130b252b794a..d6b896215c47 100644
--- a/ios/MullvadVPNUITests/RelayTests.swift
+++ b/ios/MullvadVPNUITests/RelayTests.swift
@@ -27,7 +27,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
         super.tearDown()
 
         if removeFirewallRulesInTearDown {
-            FirewallAPIClient().removeRules()
+            FirewallClient().removeRules()
         }
     }
 
@@ -102,7 +102,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
     }
 
     func testConnectionRetryLogic() throws {
-        FirewallAPIClient().removeRules()
+        FirewallClient().removeRules()
         removeFirewallRulesInTearDown = true
 
         addTeardownBlock {
@@ -113,7 +113,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
         let relayInfo = getDefaultRelayInfo()
 
         // Run actual test
-        try FirewallAPIClient().createRule(
+        try FirewallClient().createRule(
             FirewallRule.makeBlockAllTrafficRule(toIPAddress: relayInfo.ipAddress)
         )
 
@@ -215,7 +215,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
 
     /// Test automatic switching to TCP is functioning when UDP traffic to relay is blocked. This test first connects to a realy to get the IP address of it, in order to block UDP traffic to this relay.
     func testWireGuardOverTCPAutomatically() throws {
-        FirewallAPIClient().removeRules()
+        FirewallClient().removeRules()
         removeFirewallRulesInTearDown = true
 
         addTeardownBlock {
@@ -226,7 +226,7 @@ class RelayTests: LoggedInWithTimeUITestCase {
         let relayInfo = getDefaultRelayInfo()
 
         // Run actual test
-        try FirewallAPIClient().createRule(
+        try FirewallClient().createRule(
             FirewallRule.makeBlockUDPTrafficRule(toIPAddress: relayInfo.ipAddress)
         )
 
diff --git a/ios/MullvadVPNUITests/tests.json b/ios/MullvadVPNUITests/tests.json
index 3841f81c2282..ab6a0ff3de64 100644
--- a/ios/MullvadVPNUITests/tests.json
+++ b/ios/MullvadVPNUITests/tests.json
@@ -4,6 +4,7 @@
             "AccountTests",
             "ConnectivityTests",
             "CustomListsTests",
+            "LeakTests",
             "RelayTests",
             "SettingsTests"
         ],
diff --git a/mullvad-cli/src/format.rs b/mullvad-cli/src/format.rs
index ada97cbd5de3..fdf105e51fca 100644
--- a/mullvad-cli/src/format.rs
+++ b/mullvad-cli/src/format.rs
@@ -194,7 +194,7 @@ fn print_connection_info(
     for (name, value) in current_info
         .into_iter()
         // Hack that puts important items first, e.g. "Relay"
-        .sorted_by_key(|(name, _)| name.len())
+        .sorted_by_key(|(name, _)| ( name.len(), name.to_owned() ))
     {
         let previous_value = previous_info.get(name).and_then(|i| i.clone());
         match (value, previous_value) {
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index fbd60a8e79ab..6e91e6efa133 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -790,6 +790,8 @@ impl Daemon {
             mullvad_types::TUNNEL_FWMARK,
             #[cfg(target_os = "linux")]
             mullvad_types::TUNNEL_TABLE_ID,
+            #[cfg(target_os = "android")]
+            config.android_context.clone(),
         )
         .await
         .map_err(Error::RouteManager)?;
diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs
index 8312657efb1e..6245bd901f7b 100644
--- a/mullvad-jni/src/classes.rs
+++ b/mullvad-jni/src/classes.rs
@@ -7,13 +7,17 @@ pub const CLASSES: &[&str] = &[
     "net/mullvad/mullvadvpn/service/MullvadVpnService",
     "net/mullvad/talpid/model/InetNetwork",
     "net/mullvad/talpid/model/TunConfig",
+    "net/mullvad/talpid/model/NetworkState",
+    "net/mullvad/talpid/model/RouteInfo",
     "net/mullvad/talpid/model/CreateTunResult$Success",
     "net/mullvad/talpid/model/CreateTunResult$InvalidDnsServers",
     "net/mullvad/talpid/model/CreateTunResult$OtherLegacyAlwaysOnVpn",
     "net/mullvad/talpid/model/CreateTunResult$OtherAlwaysOnApp",
     "net/mullvad/talpid/model/CreateTunResult$NotPrepared",
-    "net/mullvad/talpid/model/CreateTunResult$TunnelDeviceError",
+    "net/mullvad/talpid/model/CreateTunResult$EstablishError",
     "net/mullvad/talpid/ConnectivityListener",
     "net/mullvad/talpid/TalpidVpnService",
     "net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointOverride",
+    "net/mullvad/talpid/model/Connectivity$Status",
+    "net/mullvad/talpid/model/Connectivity$PresumeOnline",
 ];
diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs
index fd35396fd00f..8b1018d926ab 100644
--- a/mullvad-jni/src/lib.rs
+++ b/mullvad-jni/src/lib.rs
@@ -3,7 +3,6 @@
 mod api;
 mod classes;
 mod problem_report;
-mod talpid_vpn_service;
 
 use jnix::{
     jni::{
@@ -88,17 +87,25 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initial
     assert!(ctx.is_none(), "multiple calls to MullvadDaemon.initialize");
 
     let env = JnixEnv::from(env);
+    let files_dir = pathbuf_from_java(&env, files_directory);
+    start_logging(&files_dir)
+        .map_err(Error::InitializeLogging)
+        .unwrap();
+    version::log_version();
 
+    log::info!("Pre-loading classes!");
     LOAD_CLASSES.call_once(|| env.preload_classes(classes::CLASSES.iter().cloned()));
+    log::info!("Done loading classes");
 
     let rpc_socket = pathbuf_from_java(&env, rpc_socket_path);
-    let files_dir = pathbuf_from_java(&env, files_directory);
     let cache_dir = pathbuf_from_java(&env, cache_directory);
 
     let android_context = ok_or_throw!(&env, create_android_context(&env, vpn_service));
+    log::info!("Created Android Context");
 
     let api_endpoint = api::api_endpoint_from_java(&env, api_endpoint);
 
+    log::info!("Starting daemon");
     let daemon = ok_or_throw!(
         &env,
         start(
@@ -134,11 +141,8 @@ fn start(
     rpc_socket: PathBuf,
     files_dir: PathBuf,
     cache_dir: PathBuf,
-    api_endpoint: Option<mullvad_api::ApiEndpoint>,
+    api_endpoint: Option<ApiEndpoint>,
 ) -> Result<DaemonContext, Error> {
-    start_logging(&files_dir).map_err(Error::InitializeLogging)?;
-    version::log_version();
-
     #[cfg(not(feature = "api-override"))]
     if api_endpoint.is_some() {
         log::warn!("api_endpoint will be ignored since 'api-override' is not enabled");
diff --git a/mullvad-jni/src/talpid_vpn_service.rs b/mullvad-jni/src/talpid_vpn_service.rs
deleted file mode 100644
index ea6928538a86..000000000000
--- a/mullvad-jni/src/talpid_vpn_service.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-use ipnetwork::IpNetwork;
-use jnix::jni::{
-    objects::JObject,
-    sys::{jboolean, jint, JNI_FALSE},
-    JNIEnv,
-};
-use nix::sys::{
-    select::{pselect, FdSet},
-    time::{TimeSpec, TimeValLike},
-};
-use rand::{thread_rng, Rng};
-use std::{
-    io,
-    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket},
-    os::unix::io::RawFd,
-    time::{Duration, Instant},
-};
-use talpid_types::ErrorExt;
-
-#[derive(Debug, thiserror::Error)]
-enum Error {
-    #[error("Failed to verify the tunnel device")]
-    VerifyTunDevice(#[from] SendRandomDataError),
-
-    #[error("Failed to select() on tunnel device")]
-    Select(#[from] nix::Error),
-
-    #[error("Timed out while waiting for tunnel device to receive data")]
-    TunnelDeviceTimeout,
-}
-
-#[no_mangle]
-#[allow(non_snake_case)]
-pub extern "system" fn Java_net_mullvad_talpid_TalpidVpnService_waitForTunnelUp(
-    _: JNIEnv<'_>,
-    _this: JObject<'_>,
-    tunFd: jint,
-    isIpv6Enabled: jboolean,
-) {
-    let tun_fd = tunFd as RawFd;
-    let is_ipv6_enabled = isIpv6Enabled != JNI_FALSE;
-
-    if let Err(error) = wait_for_tunnel_up(tun_fd, is_ipv6_enabled) {
-        log::error!(
-            "{}",
-            error.display_chain_with_msg("Failed to wait for tunnel device to be usable")
-        );
-    }
-}
-
-fn wait_for_tunnel_up(tun_fd: RawFd, is_ipv6_enabled: bool) -> Result<(), Error> {
-    let mut fd_set = FdSet::new();
-    fd_set.insert(tun_fd);
-    let timeout = TimeSpec::microseconds(300);
-    const TIMEOUT: Duration = Duration::from_secs(60);
-    let start = Instant::now();
-    while start.elapsed() < TIMEOUT {
-        // if tunnel device is ready to be read from, traffic is being routed through it
-        if pselect(None, Some(&mut fd_set), None, None, Some(&timeout), None)? > 0 {
-            return Ok(());
-        }
-        // have to add tun_fd back into the bitset
-        fd_set.insert(tun_fd);
-        try_sending_random_udp(is_ipv6_enabled)?;
-    }
-
-    Err(Error::TunnelDeviceTimeout)
-}
-
-#[derive(Debug, thiserror::Error)]
-enum SendRandomDataError {
-    #[error("Failed to bind an UDP socket")]
-    BindUdpSocket(#[source] io::Error),
-
-    #[error("Failed to send random data through UDP socket")]
-    SendToUdpSocket(#[source] io::Error),
-}
-
-fn try_sending_random_udp(is_ipv6_enabled: bool) -> Result<(), SendRandomDataError> {
-    let mut tried_ipv6 = false;
-    const TIMEOUT: Duration = Duration::from_millis(300);
-    let start = Instant::now();
-
-    while start.elapsed() < TIMEOUT {
-        // TODO: if we are to allow LAN on Android by changing the routes that are stuffed in
-        // TunConfig, then this should be revisited to be fair between IPv4 and IPv6
-        let should_generate_ipv4 = !is_ipv6_enabled || tried_ipv6 || thread_rng().gen();
-        let (bound_addr, random_public_addr) = random_socket_addrs(should_generate_ipv4);
-
-        tried_ipv6 |= random_public_addr.ip().is_ipv6();
-
-        let socket = UdpSocket::bind(bound_addr).map_err(SendRandomDataError::BindUdpSocket)?;
-        match socket.send_to(&random_data(), random_public_addr) {
-            Ok(_) => return Ok(()),
-            // Always retry on IPv6 errors
-            Err(_) if random_public_addr.ip().is_ipv6() => continue,
-            Err(_err) if matches!(_err.raw_os_error(), Some(22) | Some(101)) => {
-                // Error code 101 - specified network is unreachable
-                // Error code 22 - specified address is not usable
-                continue;
-            }
-            Err(err) => return Err(SendRandomDataError::SendToUdpSocket(err)),
-        }
-    }
-    Ok(())
-}
-
-fn random_data() -> Vec<u8> {
-    let mut buf = vec![0u8; thread_rng().gen_range(17..214)];
-    thread_rng().fill(buf.as_mut_slice());
-    buf
-}
-
-/// Returns a random local and public destination socket address.
-/// If `ipv4` is true, then IPv4 addresses will be returned. Otherwise, IPv6 addresses will be
-/// returned.
-fn random_socket_addrs(ipv4: bool) -> (SocketAddr, SocketAddr) {
-    loop {
-        let rand_port = thread_rng().gen();
-        let (local_addr, rand_dest_addr) = if ipv4 {
-            let mut ipv4_bytes = [0u8; 4];
-            thread_rng().fill(&mut ipv4_bytes);
-            (
-                SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), 0),
-                SocketAddr::new(IpAddr::from(ipv4_bytes), rand_port),
-            )
-        } else {
-            let mut ipv6_bytes = [0u8; 16];
-            thread_rng().fill(&mut ipv6_bytes);
-            (
-                SocketAddr::new(Ipv6Addr::UNSPECIFIED.into(), 0),
-                SocketAddr::new(IpAddr::from(ipv6_bytes), rand_port),
-            )
-        };
-
-        // TODO: once https://github.com/rust-lang/rust/issues/27709 is resolved, please use
-        // `is_global()` to check if a new address should be attempted.
-        if !is_public_ip(rand_dest_addr.ip()) {
-            continue;
-        }
-
-        return (local_addr, rand_dest_addr);
-    }
-}
-
-fn is_public_ip(addr: IpAddr) -> bool {
-    match addr {
-        IpAddr::V4(ipv4) => {
-            // 0.x.x.x is not a publicly routable address
-            if ipv4.octets()[0] == 0u8 {
-                return false;
-            }
-        }
-        IpAddr::V6(ipv6) => {
-            if ipv6.segments()[0] == 0u16 {
-                return false;
-            }
-        }
-    }
-    // A non-exhaustive list of non-public subnets
-    let publicly_unroutable_subnets: Vec<IpNetwork> = vec![
-        // IPv4 local networks
-        "10.0.0.0/8".parse().unwrap(),
-        "172.16.0.0/12".parse().unwrap(),
-        "192.168.0.0/16".parse().unwrap(),
-        // IPv4 non-forwardable network
-        "169.254.0.0/16".parse().unwrap(),
-        "192.0.0.0/8".parse().unwrap(),
-        // Documentation networks
-        "192.0.2.0/24".parse().unwrap(),
-        "198.51.100.0/24".parse().unwrap(),
-        "203.0.113.0/24".parse().unwrap(),
-        // IPv6 publicly unroutable networks
-        "fc00::/7".parse().unwrap(),
-        "fe80::/10".parse().unwrap(),
-    ];
-
-    !publicly_unroutable_subnets
-        .iter()
-        .any(|net| net.contains(addr))
-}
diff --git a/scripts/utils/gh-ready-check b/scripts/utils/gh-ready-check
new file mode 100755
index 000000000000..164b5de903f6
--- /dev/null
+++ b/scripts/utils/gh-ready-check
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+# This script controls that the gh (GitHub CLI) command is installed and authenticated. This can be
+# called in the beginning of all scripts depending on gh.
+
+set -eu
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+# shellcheck source=scripts/utils/log
+source ./log
+
+if ! command -v gh > /dev/null; then
+    log_error "gh (GitHub CLI) is required to run this script"
+    exit 1
+fi
+
+if ! gh auth status > /dev/null; then
+    log_error "Authentication through gh (GitHub CLI) is required to run this script"
+    exit 1
+fi
diff --git a/scripts/utils/print-and-run b/scripts/utils/print-and-run
new file mode 100755
index 000000000000..b323040f3c99
--- /dev/null
+++ b/scripts/utils/print-and-run
@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+
+# This is a small utility script that can be sourced to provide a function that prints its arguments
+# and then calls them as a command, e.g. `print_and_run sleep 5`.
+
+function print_and_run {
+    echo "+ $*"
+    "$@"
+}
diff --git a/talpid-core/src/connectivity_listener.rs b/talpid-core/src/connectivity_listener.rs
index 9bdf4bf87ab7..a0c33a77f2b4 100644
--- a/talpid-core/src/connectivity_listener.rs
+++ b/talpid-core/src/connectivity_listener.rs
@@ -98,36 +98,34 @@ impl ConnectivityListener {
 
     /// Return the current offline/connectivity state
     pub fn connectivity(&self) -> Connectivity {
-        self.get_is_connected()
-            .map(|connected| Connectivity::Status { connected })
-            .unwrap_or_else(|error| {
-                log::error!(
-                    "{}",
-                    error.display_chain_with_msg("Failed to check connectivity status")
-                );
-                Connectivity::PresumeOnline
-            })
+        self.get_is_connected().unwrap_or_else(|error| {
+            log::error!(
+                "{}",
+                error.display_chain_with_msg("Failed to check connectivity status")
+            );
+            Connectivity::PresumeOnline
+        })
     }
 
-    fn get_is_connected(&self) -> Result<bool, Error> {
+    fn get_is_connected(&self) -> Result<Connectivity, Error> {
         let env = JnixEnv::from(
             self.jvm
                 .attach_current_thread_as_daemon()
                 .map_err(Error::AttachJvmToThread)?,
         );
 
-        let is_connected =
-            env.call_method(self.android_listener.as_obj(), "isConnected", "()Z", &[]);
-
-        match is_connected {
-            Ok(JValue::Bool(JNI_TRUE)) => Ok(true),
-            Ok(JValue::Bool(_)) => Ok(false),
-            value => Err(Error::InvalidMethodResult(
-                "ConnectivityListener",
+        let is_connected = env
+            .call_method(
+                self.android_listener.as_obj(),
                 "isConnected",
-                format!("{:?}", value),
-            )),
-        }
+                "()Lnet/mullvad/talpid/model/Connectivity;",
+                &[],
+            )
+            .expect("Missing isConnected")
+            .l()
+            .expect("isConnected is not an object");
+
+        Ok(Connectivity::from_java(&env, is_connected))
     }
 
     /// Return the current DNS servers according to Android
@@ -160,9 +158,10 @@ impl ConnectivityListener {
 #[no_mangle]
 #[allow(non_snake_case)]
 pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyConnectivityChange(
-    _: JNIEnv<'_>,
-    _: JObject<'_>,
-    connected: jboolean,
+    _env: JNIEnv<'_>,
+    _obj: JObject<'_>,
+    is_ipv4: jboolean,
+    is_ipv6: jboolean,
 ) {
     let Some(tx) = &*CONNECTIVITY_TX.lock().unwrap() else {
         // No sender has been registered
@@ -170,10 +169,14 @@ pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyConnec
         return;
     };
 
-    let connected = JNI_TRUE == connected;
+    let is_ipv4 = JNI_TRUE == is_ipv4;
+    let is_ipv6 = JNI_TRUE == is_ipv6;
 
     if tx
-        .unbounded_send(Connectivity::Status { connected })
+        .unbounded_send(Connectivity::Status {
+            ipv4: is_ipv4,
+            ipv6: is_ipv6,
+        })
         .is_err()
     {
         log::warn!("Failed to send offline change event");
diff --git a/talpid-core/src/tunnel_state_machine/connecting_state.rs b/talpid-core/src/tunnel_state_machine/connecting_state.rs
index 7c7637cd20c2..9060787536db 100644
--- a/talpid-core/src/tunnel_state_machine/connecting_state.rs
+++ b/talpid-core/src/tunnel_state_machine/connecting_state.rs
@@ -138,6 +138,7 @@ impl ConnectingState {
                         &shared_values.route_manager,
                         retry_attempt,
                     );
+
                     let params = connecting_state.tunnel_parameters.clone();
                     (
                         Box::new(connecting_state),
diff --git a/talpid-routing/Cargo.toml b/talpid-routing/Cargo.toml
index 14f30b83338a..b4d3e2a747d9 100644
--- a/talpid-routing/Cargo.toml
+++ b/talpid-routing/Cargo.toml
@@ -16,10 +16,11 @@ futures = { workspace = true }
 ipnetwork = { workspace = true }
 log = { workspace = true }
 tokio = { workspace = true, features = ["process", "rt-multi-thread", "net", "io-util", "time"] }
-
-[target.'cfg(not(target_os="android"))'.dependencies]
 talpid-types = { path = "../talpid-types" }
 
+[target.'cfg(target_os = "android")'.dependencies]
+jnix = { version = "0.5.2", features = ["derive"] }
+
 [target.'cfg(target_os = "linux")'.dependencies]
 libc = "0.2"
 rtnetlink = "0.11"
diff --git a/talpid-routing/src/lib.rs b/talpid-routing/src/lib.rs
index b80f96ccdc56..89effbdd466b 100644
--- a/talpid-routing/src/lib.rs
+++ b/talpid-routing/src/lib.rs
@@ -24,7 +24,10 @@ mod imp;
 use netlink_packet_route::rtnl::constants::RT_TABLE_MAIN;
 
 #[cfg(target_os = "macos")]
-pub use imp::{imp::RouteError, DefaultRouteEvent, PlatformError};
+pub use imp::{
+    imp::{DefaultRouteEvent, RouteError},
+    PlatformError,
+};
 
 pub use imp::{Error, RouteManagerHandle};
 
@@ -70,6 +73,7 @@ pub struct Gateway {
 }
 
 /// A network route with a specific network node, destination and an optional metric.
+#[cfg(not(target_os = "android"))]
 #[derive(Debug, Hash, Eq, PartialEq, Clone)]
 pub struct Route {
     node: Node,
@@ -81,8 +85,14 @@ pub struct Route {
     mtu: Option<u32>,
 }
 
+/// A network route with a specific network node, destination and an optional metric.
+#[cfg(target_os = "android")]
+#[derive(Debug, Hash, Eq, PartialEq, Clone)]
+pub struct Route(IpNetwork);
+
 impl Route {
     /// Construct a new Route
+    #[cfg(not(target_os = "android"))]
     pub fn new(node: Node, prefix: IpNetwork) -> Self {
         Self {
             node,
@@ -95,6 +105,12 @@ impl Route {
         }
     }
 
+    /// Construct a new Route
+    #[cfg(target_os = "android")]
+    pub fn new(prefix: IpNetwork) -> Self {
+        Self(prefix)
+    }
+
     #[cfg(target_os = "linux")]
     fn table(mut self, new_id: u32) -> Self {
         self.table_id = new_id;
@@ -102,11 +118,13 @@ impl Route {
     }
 
     /// Returns the network node of the route.
+    #[cfg(target_os = "linux")]
     pub fn get_node(&self) -> &Node {
         &self.node
     }
 }
 
+#[cfg(target_os = "linux")]
 impl fmt::Display for Route {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         write!(f, "{} via {}", self.prefix, self.node)?;
@@ -123,9 +141,22 @@ impl fmt::Display for Route {
     }
 }
 
+#[cfg(target_os = "android")]
+impl From<&talpid_types::android::RouteInfo> for Route {
+    fn from(route_info: &talpid_types::android::RouteInfo) -> Self {
+        let network = IpNetwork::new(
+            route_info.destination.address,
+            route_info.destination.prefix_length as u8,
+        )
+        .unwrap();
+        Self::new(network)
+    }
+}
+
 /// A network route that should be applied by the route manager.
 /// It can either be routed through a specific network node or it can be routed through the current
 /// default route.
+#[cfg(not(target_os = "android"))]
 #[derive(Debug, Hash, Eq, PartialEq, Clone)]
 pub struct RequiredRoute {
     /// Route's prefix
@@ -139,6 +170,7 @@ pub struct RequiredRoute {
     mtu: Option<u16>,
 }
 
+#[cfg(not(target_os = "android"))]
 impl RequiredRoute {
     /// Constructs a new required route.
     pub fn new(prefix: IpNetwork, node: impl Into<NetNode>) -> Self {
diff --git a/talpid-routing/src/unix/android.rs b/talpid-routing/src/unix/android.rs
index 8abb23859bfd..be9f8b7d6ade 100644
--- a/talpid-routing/src/unix/android.rs
+++ b/talpid-routing/src/unix/android.rs
@@ -1,37 +1,232 @@
-use crate::imp::RouteManagerCommand;
-use futures::{channel::mpsc, stream::StreamExt};
+use std::collections::HashSet;
+use std::ops::{ControlFlow, Not};
+use std::sync::Mutex;
+
+use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
+use futures::channel::oneshot;
+use futures::future::FutureExt;
+use futures::select_biased;
+use futures::stream::StreamExt;
+use jnix::jni::objects::JValue;
+use jnix::jni::{objects::JObject, JNIEnv};
+use jnix::{FromJava, JnixEnv};
+
+use talpid_types::android::{AndroidContext, NetworkState};
+
+use crate::{imp::RouteManagerCommand, Route};
 
 /// Stub error type for routing errors on Android.
+/// Errors that occur while setting up VpnService tunnel.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    /// Timed out when waiting for network routes.
+    #[error("Timed out when waiting for network routes")]
+    RoutesTimedOut,
+}
+
+/// Internal errors that may only happen during the initial poll for [NetworkState].
 #[derive(Debug, thiserror::Error)]
-#[error("Failed to send shutdown result")]
-pub struct Error;
+enum JvmError {
+    #[error("Failed to attach Java VM to tunnel thread")]
+    AttachJvmToThread(#[source] jnix::jni::errors::Error),
+    #[error("Failed to call Java method {0}")]
+    CallMethod(&'static str, #[source] jnix::jni::errors::Error),
+    #[error("Failed to create global reference to Java object")]
+    CreateGlobalRef(#[source] jnix::jni::errors::Error),
+    #[error("Received an invalid result from {0}.{1}: {2}")]
+    InvalidMethodResult(&'static str, &'static str, String),
+}
+
+/// The sender used by [Java_net_mullvad_talpid_ConnectivityListener_notifyDefaultNetworkChange]
+/// to notify the route manager of changes to the network.
+static ROUTE_UPDATES_TX: Mutex<Option<UnboundedSender<Option<NetworkState>>>> = Mutex::new(None);
+
+/// Android route manager actor.
+#[derive(Debug)]
+pub struct RouteManagerImpl {
+    /// The receiving channel for updates on changes to the network.
+    network_state_updates: UnboundedReceiver<Option<NetworkState>>,
 
-/// Stub route manager for Android
-pub struct RouteManagerImpl {}
+    /// Cached [NetworkState]. If no update events have been received yet, this value will be [None].
+    last_state: Option<NetworkState>,
+
+    /// Clients waiting on response to [RouteManagerCommand::WaitForRoutes].
+    waiting_for_routes: Vec<oneshot::Sender<()>>,
+}
 
 impl RouteManagerImpl {
     #[allow(clippy::unused_async)]
-    pub async fn new() -> Result<Self, Error> {
-        Ok(RouteManagerImpl {})
+    pub async fn new(android_context: AndroidContext) -> Result<Self, Error> {
+        // Create a channel between the kotlin client and route manager
+        let (tx, rx) = futures::channel::mpsc::unbounded();
+
+        *ROUTE_UPDATES_TX.lock().unwrap() = Some(tx);
+
+        // Try to poll for the current network state at startup.
+        // This will most likely be null, but it covers the edge case where a NetworkState
+        // update has been emitted before we anyone starts to listen for route updates some
+        // time in the future (when connecting).
+        let last_state = match current_network_state(android_context) {
+            Ok(initial_state) => initial_state,
+            Err(err) => {
+                log::error!("Failed while polling for initial NetworkState");
+                log::error!("{err}");
+                None
+            }
+        };
+
+        let route_manager = RouteManagerImpl {
+            network_state_updates: rx,
+            last_state,
+            waiting_for_routes: Default::default(),
+        };
+
+        Ok(route_manager)
     }
 
     pub(crate) async fn run(
-        self,
+        mut self,
         manage_rx: mpsc::UnboundedReceiver<RouteManagerCommand>,
     ) -> Result<(), Error> {
         let mut manage_rx = manage_rx.fuse();
-        while let Some(command) = manage_rx.next().await {
-            match command {
-                RouteManagerCommand::Shutdown(tx) => {
-                    tx.send(()).map_err(|()| Error)?;
-                    break;
+
+        loop {
+            select_biased! {
+                command = manage_rx.next().fuse() => {
+                    let Some(command) = command else { break };
+                    if self.handle_command(command).is_break() {
+                        break;
+                    }
                 }
-                RouteManagerCommand::AddRoutes(_routes, tx) => {
-                    let _ = tx.send(Ok(()));
+
+                network_state_update = self.network_state_updates.next().fuse() => {
+                    // None means that the sender was dropped
+                    let Some(network_state) = network_state_update else { break };
+                    // update the last known NetworkState
+                    self.last_state = network_state;
+
+                    if has_routes(self.last_state.as_ref()) {
+                        // notify waiting clients that routes exist
+                        for client in self.waiting_for_routes.drain(..) {
+                            let _ = client.send(());
+                        }
+                    }
                 }
-                RouteManagerCommand::ClearRoutes => (),
             }
         }
+
+        log::debug!("RouteManager exited");
+
         Ok(())
     }
+
+    fn handle_command(&mut self, command: RouteManagerCommand) -> ControlFlow<()> {
+        match command {
+            RouteManagerCommand::Shutdown(tx) => {
+                let _ = tx.send(());
+                return ControlFlow::Break(());
+            }
+            RouteManagerCommand::WaitForRoutes(response_tx) => {
+                // check if routes have already been configured on the Android system.
+                // otherwise, register a listener for network state changes.
+                // routes may come in at any moment in the future.
+                if has_routes(self.last_state.as_ref()) {
+                    let _ = response_tx.send(());
+                } else {
+                    self.waiting_for_routes.push(response_tx);
+                }
+            }
+        }
+
+        ControlFlow::Continue(())
+    }
+}
+
+/// Check whether the [NetworkState] contains any routes.
+///
+/// Since we are the ones telling Android what routes to set, we make the assumption that:
+/// If any routes exist whatsoever, they are the the routes we specified.
+fn has_routes(state: Option<&NetworkState>) -> bool {
+    let Some(network_state) = state else {
+        return false;
+    };
+    configured_routes(network_state).is_empty().not()
+}
+
+fn configured_routes(state: &NetworkState) -> HashSet<Route> {
+    match &state.routes {
+        None => Default::default(),
+        Some(route_info) => route_info.iter().map(Route::from).collect(),
+    }
+}
+
+/// Entry point for Android Java code to notify the current default network state.
+#[no_mangle]
+#[allow(non_snake_case)]
+pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyDefaultNetworkChange(
+    env: JNIEnv<'_>,
+    _: JObject<'_>,
+    network_state: JObject<'_>,
+) {
+    let env = JnixEnv::from(env);
+
+    let network_state: Option<NetworkState> = FromJava::from_java(&env, network_state);
+
+    let Some(tx) = &*ROUTE_UPDATES_TX.lock().unwrap() else {
+        // No sender has been registered
+        log::error!("Received routes notification wíth no channel");
+        return;
+    };
+
+    log::trace!("Received network state update {:#?}", network_state);
+
+    if tx.unbounded_send(network_state).is_err() {
+        log::warn!("Failed to send offline change event");
+    }
+}
+
+/// Return the current NetworkState according to Android
+fn current_network_state(
+    android_context: AndroidContext,
+) -> Result<Option<NetworkState>, JvmError> {
+    let env = JnixEnv::from(
+        android_context
+            .jvm
+            .attach_current_thread_as_daemon()
+            .map_err(JvmError::AttachJvmToThread)?,
+    );
+
+    let result = env
+        .call_method(
+            android_context.vpn_service.as_obj(),
+            "getConnectivityListener",
+            "()Lnet/mullvad/talpid/ConnectivityListener;",
+            &[],
+        )
+        .map_err(|cause| JvmError::CallMethod("getConnectivityListener", cause))?;
+
+    let connectivity_listener = match result {
+        JValue::Object(object) => env
+            .new_global_ref(object)
+            .map_err(JvmError::CreateGlobalRef)?,
+        value => {
+            return Err(JvmError::InvalidMethodResult(
+                "MullvadVpnService",
+                "getConnectivityListener",
+                format!("{:?}", value),
+            ))
+        }
+    };
+
+    let network_state = env
+        .call_method(
+            connectivity_listener.as_obj(),
+            "getCurrentDefaultNetworkState",
+            "()Lnet/mullvad/talpid/model/NetworkState;",
+            &[],
+        )
+        .map_err(|cause| JvmError::CallMethod("getCurrentDefaultNetworkState", cause))?;
+
+    let network_state: Option<NetworkState> = FromJava::from_java(&env, network_state);
+    Ok(network_state)
 }
diff --git a/talpid-routing/src/unix/linux.rs b/talpid-routing/src/unix/linux.rs
index 92b4513301d3..a43f0690bc3b 100644
--- a/talpid-routing/src/unix/linux.rs
+++ b/talpid-routing/src/unix/linux.rs
@@ -86,6 +86,7 @@ pub type Result<T> = std::result::Result<T, Error>;
 
 /// Errors that can happen in the Linux routing integration
 #[derive(thiserror::Error, Debug)]
+#[allow(missing_docs)]
 pub enum Error {
     #[error("Failed to open a netlink connection")]
     Connect(#[source] io::Error),
diff --git a/talpid-routing/src/unix/macos/mod.rs b/talpid-routing/src/unix/macos/mod.rs
index 85a020ba797f..df89767e38fa 100644
--- a/talpid-routing/src/unix/macos/mod.rs
+++ b/talpid-routing/src/unix/macos/mod.rs
@@ -16,9 +16,10 @@ use std::{
 use talpid_types::ErrorExt;
 use watch::RoutingTable;
 
-use super::{DefaultRouteEvent, RouteManagerCommand};
+use super::RouteManagerCommand;
 use data::{Destination, RouteDestination, RouteMessage, RouteSocketMessage};
 
+pub use super::DefaultRouteEvent;
 pub use interface::DefaultRoute;
 
 mod data;
diff --git a/talpid-routing/src/unix/mod.rs b/talpid-routing/src/unix/mod.rs
index 34d2570137c6..042360d52027 100644
--- a/talpid-routing/src/unix/mod.rs
+++ b/talpid-routing/src/unix/mod.rs
@@ -1,18 +1,23 @@
-#[cfg(target_os = "linux")]
-use crate::Route;
 #[cfg(target_os = "macos")]
 pub use crate::{imp::imp::DefaultRoute, Gateway};
 
+#[cfg(any(target_os = "linux", target_os = "macos"))]
 use super::RequiredRoute;
+#[cfg(target_os = "linux")]
+use super::Route;
 
 use futures::channel::{
     mpsc::{self, UnboundedSender},
     oneshot,
 };
-use std::{collections::HashSet, sync::Arc};
+use std::sync::Arc;
+#[cfg(target_os = "android")]
+use talpid_types::android::AndroidContext;
 
 #[cfg(any(target_os = "linux", target_os = "macos"))]
 use futures::stream::Stream;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use std::collections::HashSet;
 
 #[cfg(target_os = "linux")]
 use std::net::IpAddr;
@@ -32,6 +37,7 @@ mod imp;
 #[path = "android.rs"]
 mod imp;
 
+#[cfg(any(target_os = "macos", target_os = "linux"))]
 pub use imp::Error as PlatformError;
 
 /// Errors that can be encountered whilst interacting with a [RouteManagerHandle].
@@ -97,11 +103,7 @@ pub(crate) enum RouteManagerCommand {
 #[cfg(target_os = "android")]
 #[derive(Debug)]
 pub(crate) enum RouteManagerCommand {
-    AddRoutes(
-        HashSet<RequiredRoute>,
-        oneshot::Sender<Result<(), PlatformError>>,
-    ),
-    ClearRoutes,
+    WaitForRoutes(oneshot::Sender<()>),
     Shutdown(oneshot::Sender<()>),
 }
 
@@ -165,6 +167,7 @@ impl RouteManagerHandle {
     pub async fn spawn(
         #[cfg(target_os = "linux")] fwmark: u32,
         #[cfg(target_os = "linux")] table_id: u32,
+        #[cfg(target_os = "android")] android_context: AndroidContext,
     ) -> Result<Self, Error> {
         let (manage_tx, manage_rx) = mpsc::unbounded();
         let manage_tx = Arc::new(manage_tx);
@@ -175,6 +178,8 @@ impl RouteManagerHandle {
             table_id,
             #[cfg(target_os = "macos")]
             Arc::downgrade(&manage_tx),
+            #[cfg(target_os = "android")]
+            android_context,
         )
         .await?;
         tokio::spawn(manager.run(manage_rx));
@@ -192,6 +197,7 @@ impl RouteManagerHandle {
     }
 
     /// Applies the given routes until they are cleared
+    #[cfg(not(target_os = "android"))]
     pub async fn add_routes(&self, routes: HashSet<RequiredRoute>) -> Result<(), Error> {
         let (result_tx, result_rx) = oneshot::channel();
         self.tx
@@ -204,13 +210,43 @@ impl RouteManagerHandle {
             .map_err(Error::PlatformError)
     }
 
+    /// Wait for routes to come up.
+    ///
+    /// This function is guaranteed to *not* wait for longer than 2 seconds.
+    /// Please, see the implementation of this function for further details.
+    #[cfg(target_os = "android")]
+    pub async fn wait_for_routes(&self) -> Result<(), Error> {
+        use std::time::Duration;
+        use tokio::time::timeout;
+        /// Maximum time to wait for routes to come up. The expected mean time is low (~200 ms), but
+        /// we add some additional margin to give some slack to slower hardware primarily.
+        const WAIT_FOR_ROUTES_TIMEOUT: Duration = Duration::from_secs(2);
+
+        let (result_tx, result_rx) = oneshot::channel();
+        self.tx
+            .unbounded_send(RouteManagerCommand::WaitForRoutes(result_tx))
+            .map_err(|_| Error::RouteManagerDown)?;
+
+        timeout(WAIT_FOR_ROUTES_TIMEOUT, result_rx)
+            .await
+            .map_err(|_error| Error::PlatformError(imp::Error::RoutesTimedOut))?
+            .map_err(|_| Error::ManagerChannelDown)
+    }
+
     /// Removes all routes previously applied in [`RouteManagerHandle::add_routes`].
+    #[cfg(not(target_os = "android"))]
     pub fn clear_routes(&self) -> Result<(), Error> {
         self.tx
             .unbounded_send(RouteManagerCommand::ClearRoutes)
             .map_err(|_| Error::RouteManagerDown)
     }
 
+    /// (Android) This is a noop since we don't directly control the routes on Android.
+    #[cfg(target_os = "android")]
+    pub fn clear_routes(&self) -> Result<(), Error> {
+        Ok(())
+    }
+
     /// Listen for non-tunnel default route changes.
     #[cfg(target_os = "macos")]
     pub async fn default_route_listener(
diff --git a/talpid-tunnel/src/tun_provider/android/mod.rs b/talpid-tunnel/src/tun_provider/android/mod.rs
index 3d356e50d328..f285b4a64ca1 100644
--- a/talpid-tunnel/src/tun_provider/android/mod.rs
+++ b/talpid-tunnel/src/tun_provider/android/mod.rs
@@ -46,6 +46,9 @@ pub enum Error {
     #[error("Failed to create tunnel device")]
     TunnelDeviceError,
 
+    #[error("Routes timed out")]
+    RoutesTimedOut,
+
     #[error("Profile for VPN has not been setup")]
     NotPrepared,
 
@@ -381,7 +384,7 @@ impl AsRawFd for VpnServiceTun {
 enum CreateTunResult {
     Success { tun_fd: i32 },
     InvalidDnsServers { addresses: Vec<IpAddr> },
-    TunnelDeviceError,
+    EstablishError,
     OtherLegacyAlwaysOnVpn,
     OtherAlwaysOnApp { app_name: String },
     NotPrepared,
@@ -394,7 +397,7 @@ impl From<CreateTunResult> for Result<RawFd, Error> {
             CreateTunResult::InvalidDnsServers { addresses } => {
                 Err(Error::InvalidDnsServers(addresses))
             }
-            CreateTunResult::TunnelDeviceError => Err(Error::TunnelDeviceError),
+            CreateTunResult::EstablishError => Err(Error::TunnelDeviceError),
             CreateTunResult::OtherLegacyAlwaysOnVpn => Err(Error::OtherLegacyAlwaysOnVpn),
             CreateTunResult::OtherAlwaysOnApp { app_name } => {
                 Err(Error::OtherAlwaysOnApp { app_name })
diff --git a/talpid-types/src/android/mod.rs b/talpid-types/src/android/mod.rs
index 4169216f3b5e..e4326fe81f50 100644
--- a/talpid-types/src/android/mod.rs
+++ b/talpid-types/src/android/mod.rs
@@ -1,8 +1,61 @@
+use ipnetwork::{IpNetwork, IpNetworkError, Ipv4Network, Ipv6Network};
 use jnix::jni::{objects::GlobalRef, JavaVM};
+use jnix::{FromJava, IntoJava};
+use std::net::IpAddr;
 use std::sync::Arc;
 
+/// What Java calls an [IpAddr]
+pub type InetAddress = IpAddr;
+
 #[derive(Clone)]
 pub struct AndroidContext {
     pub jvm: Arc<JavaVM>,
     pub vpn_service: GlobalRef,
 }
+
+/// A Java-compatible variant of [IpNetwork]
+#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)]
+#[jnix(package = "net.mullvad.talpid.model")]
+pub struct InetNetwork {
+    pub address: IpAddr,
+    pub prefix_length: i16,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)]
+#[jnix(package = "net.mullvad.talpid.model")]
+pub struct RouteInfo {
+    pub destination: InetNetwork,
+    pub gateway: Option<InetAddress>,
+    pub interface_name: Option<String>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Hash, IntoJava, FromJava)]
+#[jnix(package = "net.mullvad.talpid.model")]
+pub struct NetworkState {
+    pub network_handle: i64,
+    pub routes: Option<Vec<RouteInfo>>,
+    pub dns_servers: Option<Vec<InetAddress>>,
+}
+
+impl From<IpNetwork> for InetNetwork {
+    fn from(ip_network: IpNetwork) -> Self {
+        InetNetwork {
+            address: ip_network.ip(),
+            prefix_length: ip_network.prefix() as i16,
+        }
+    }
+}
+
+impl TryFrom<InetNetwork> for IpNetwork {
+    type Error = IpNetworkError;
+    fn try_from(inet_network: InetNetwork) -> Result<Self, Self::Error> {
+        Ok(match inet_network.address {
+            IpAddr::V4(addr) => {
+                IpNetwork::V4(Ipv4Network::new(addr, inet_network.prefix_length as u8)?)
+            }
+            IpAddr::V6(addr) => {
+                IpNetwork::V6(Ipv6Network::new(addr, inet_network.prefix_length as u8)?)
+            }
+        })
+    }
+}
diff --git a/talpid-types/src/net/mod.rs b/talpid-types/src/net/mod.rs
index 2d67fc5cfe53..e04da4acc519 100644
--- a/talpid-types/src/net/mod.rs
+++ b/talpid-types/src/net/mod.rs
@@ -1,4 +1,5 @@
 use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
+use jnix::FromJava;
 use obfuscation::ObfuscatorConfig;
 use serde::{Deserialize, Serialize};
 #[cfg(windows)]
@@ -564,20 +565,15 @@ pub fn all_of_the_internet() -> Vec<ipnetwork::IpNetwork> {
 ///
 /// Information about the host's connectivity, such as the preesence of
 /// configured IPv4 and/or IPv6.
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq, FromJava)]
+#[jnix(package = "net.mullvad.talpid.model")]
 pub enum Connectivity {
-    #[cfg(not(target_os = "android"))]
     Status {
         /// Whether IPv4 connectivity seems to be available on the host.
         ipv4: bool,
         /// Whether IPv6 connectivity seems to be available on the host.
         ipv6: bool,
     },
-    #[cfg(target_os = "android")]
-    Status {
-        /// Whether _any_ connectivity seems to be available on the host.
-        connected: bool,
-    },
     /// On/offline status could not be verified, but we have no particular
     /// reason to believe that the host is offline.
     PresumeOnline,
@@ -591,7 +587,6 @@ impl Connectivity {
 
     /// If no IP4 nor IPv6 routes exist, we have no way of reaching the internet
     /// so we consider ourselves offline.
-    #[cfg(not(target_os = "android"))]
     pub fn is_offline(&self) -> bool {
         matches!(
             self,
@@ -605,23 +600,7 @@ impl Connectivity {
     /// Whether IPv6 connectivity seems to be available on the host.
     ///
     /// If IPv6 status is unknown, `false` is returned.
-    #[cfg(not(target_os = "android"))]
     pub fn has_ipv6(&self) -> bool {
         matches!(self, Connectivity::Status { ipv6: true, .. })
     }
-
-    /// Whether IPv6 connectivity seems to be available on the host.
-    ///
-    /// If IPv6 status is unknown, `false` is returned.
-    #[cfg(target_os = "android")]
-    pub fn has_ipv6(&self) -> bool {
-        self.is_online()
-    }
-
-    /// If the host does not have configured IPv6 routes, we have no way of
-    /// reaching the internet so we consider ourselves offline.
-    #[cfg(target_os = "android")]
-    pub fn is_offline(&self) -> bool {
-        matches!(self, Connectivity::Status { connected: false })
-    }
 }
diff --git a/talpid-wireguard/src/lib.rs b/talpid-wireguard/src/lib.rs
index af8e2da79e82..fe1a848e9a74 100644
--- a/talpid-wireguard/src/lib.rs
+++ b/talpid-wireguard/src/lib.rs
@@ -403,7 +403,6 @@ impl WireguardMonitor {
         let desired_mtu = get_desired_mtu(params);
         let mut config =
             Config::from_parameters(params, desired_mtu).map_err(Error::WireguardConfigError)?;
-
         let (close_obfs_sender, close_obfs_listener) = sync_mpsc::channel();
         // Start obfuscation server and patch the WireGuard config to point the endpoint to it.
         let obfuscator = args
@@ -466,6 +465,13 @@ impl WireguardMonitor {
                 .on_event(TunnelEvent::InterfaceUp(metadata.clone(), allowed_traffic))
                 .await;
 
+            // Wait for routes to come up
+            args.route_manager
+                .wait_for_routes()
+                .await
+                .map_err(Error::SetupRoutingError)
+                .map_err(CloseMsg::SetupError)?;
+
             if should_negotiate_ephemeral_peer {
                 let ephemeral_obfs_sender = close_obfs_sender.clone();
 
diff --git a/talpid-wireguard/src/wireguard_go/mod.rs b/talpid-wireguard/src/wireguard_go/mod.rs
index a3045659672e..db74ef3bacd6 100644
--- a/talpid-wireguard/src/wireguard_go/mod.rs
+++ b/talpid-wireguard/src/wireguard_go/mod.rs
@@ -361,8 +361,13 @@ impl WgGoTunnel {
         tun_config.addresses = config.tunnel.addresses.clone();
         tun_config.ipv4_gateway = config.ipv4_gateway;
         tun_config.ipv6_gateway = config.ipv6_gateway;
-        tun_config.routes = routes.collect();
         tun_config.mtu = config.mtu;
+        tun_config.routes = if cfg!(target_os = "android") {
+            // Route everything into the tunnel and have wireguard-go act as a firewall.
+            vec!["0.0.0.0/0".parse().unwrap(), "::/0".parse().unwrap()]
+        } else {
+            routes.collect()
+        };
 
         for _ in 1..=MAX_PREPARE_TUN_ATTEMPTS {
             let tunnel_device = tun_provider
diff --git a/wireguard-go-rs/Cargo.toml b/wireguard-go-rs/Cargo.toml
index b00444b7fb39..d86cb6153529 100644
--- a/wireguard-go-rs/Cargo.toml
+++ b/wireguard-go-rs/Cargo.toml
@@ -12,10 +12,13 @@ thiserror.workspace = true
 log.workspace = true
 zeroize = "1.8.1"
 
-# The app does not depend on maybenot-ffi itself, but adds it as a dependency to expose FFI symbols to wireguard-go.
-# This is done, instead of using the makefile in wireguard-go to build maybenot-ffi into its archive, to prevent
-# name clashes induced by link-time optimization.
-# NOTE: the version of maybenot-ffi below must be the same as the version checked into the wireguard-go submodule
+# On platforms where maybenot and wireguard-go can be built statically (Linux and macOS) we use
+# this hack to include it. The hack is that we depend on this crate here even if neither
+# wireguard-go-rs nor its upstream dependants use it.
+# This is only here so that maybenot-ffi is built and its symbols are available to wireguard-go
+# at link time.
+# NOTE: for other platforms, maybenot-ffi is NOT declared here, but instead built directly from
+# wireguard-go-rs/libwg/wireguard-go/maybenot-ffi
 [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
 maybenot-ffi = "2.0.1"
 
diff --git a/wireguard-go-rs/build.rs b/wireguard-go-rs/build.rs
index f13f0341162f..a544e4e161e1 100644
--- a/wireguard-go-rs/build.rs
+++ b/wireguard-go-rs/build.rs
@@ -268,11 +268,16 @@ fn build_shared_maybenot_lib(out_dir: impl AsRef<Path>) -> anyhow::Result<()> {
     tmp_build_dir = tmp_build_dir.join("target");
 
     build_command
-        .current_dir("./libwg/wireguard-go/maybenot/crates/maybenot-ffi")
+        .current_dir("./libwg/wireguard-go/maybenot-ffi")
         .env("RUSTFLAGS", "-C metadata=maybenot-ffi -Ctarget-feature=+crt-static")
-        // Set temporary target dir to prevent deadlock
+        // Set temporary target dir to prevent deadlock, since we are invoking cargo from within
+        // another cargo process.
         .env("CARGO_TARGET_DIR", &tmp_build_dir)
-        .arg("build")
+        .arg("rustc")
+        // Build a shared library to consume from another language (go)
+        .arg("--crate-type=cdylib")
+        // Always respect lockfiles
+        .args(["--locked"])
         .args(["--profile", profile])
         .args(["--target", &target_triple]);
 
diff --git a/wireguard-go-rs/libwg/Android.mk b/wireguard-go-rs/libwg/Android.mk
index 23b32cd81f80..9833c9b6a11a 100644
--- a/wireguard-go-rs/libwg/Android.mk
+++ b/wireguard-go-rs/libwg/Android.mk
@@ -3,7 +3,8 @@
 # Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
 
 DESTDIR ?= $(OUT_DIR)
-CARGO_TARGET_DIR ?=
+# Default to the workspace root if not set
+CARGO_TARGET_DIR ?= $(CURDIR)/../../target
 TARGET ?=
 
 NDK_GO_ARCH_MAP_x86 := 386
@@ -25,7 +26,7 @@ default: $(DESTDIR)/libwg.so
 $(DESTDIR)/libwg.so:
 	mkdir -p $(DESTDIR)
 	# Build libmaybenot
-	make --directory wireguard-go libmaybenot.a LIBDEST="$(DESTDIR)" TARGET="$(TARGET)" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)"
+	make --directory wireguard-go/maybenot-ffi $(DESTDIR)/libmaybenot.a TARGET="$(TARGET)" CARGO_TARGET_DIR="$(CARGO_TARGET_DIR)"
 	# Build wireguard-go
 	go get -tags "linux android daita"
 	chmod -fR +w "$(GOPATH)/pkg/mod"
diff --git a/wireguard-go-rs/libwg/wireguard-go b/wireguard-go-rs/libwg/wireguard-go
index 209814b58498..0b750ea8445a 160000
--- a/wireguard-go-rs/libwg/wireguard-go
+++ b/wireguard-go-rs/libwg/wireguard-go
@@ -1 +1 @@
-Subproject commit 209814b584985679d7602387e6402b3a30b03014
+Subproject commit 0b750ea8445a378b6f314fa6135c889e9d171b19
diff --git a/wireguard-go-rs/src/lib.rs b/wireguard-go-rs/src/lib.rs
index a98c9e056a9f..f49c4d4e52d3 100644
--- a/wireguard-go-rs/src/lib.rs
+++ b/wireguard-go-rs/src/lib.rs
@@ -29,7 +29,8 @@ pub type LoggingContext = u64;
 pub type LoggingCallback =
     unsafe extern "system" fn(level: WgLogLevel, msg: *const c_char, context: LoggingContext);
 
-// Make symbols from maybenot-ffi visible to wireguard-go
+// Make symbols from maybenot-ffi visible to wireguard-go, on the platforms where
+// wireguard-go is statically linked into this crate.
 #[cfg(all(daita, any(target_os = "linux", target_os = "macos")))]
 use maybenot_ffi as _;