summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJon Petersson <jon.petersson@mullvad.net>2026-02-27 16:25:03 +0100
committerJon Petersson <jon.petersson@mullvad.net>2026-02-27 16:25:03 +0100
commit6676acf301c498a8dcb71a35f409b89968b3a750 (patch)
treed59d5398b6a3d03f2e2b04498e02d2546610ea3f
parentfe84f9bad91e222ed768ede172fce1e2ada29bea (diff)
downloadmullvadvpn-debug-view.tar.xz
mullvadvpn-debug-view.zip
Add debug viewdebug-view
-rw-r--r--ios/Assets/Localizable.xcstrings4
-rw-r--r--ios/MullvadRustRuntime/include/mullvad_rust_runtime.h28
-rw-r--r--ios/MullvadSettings/WireGuardObfuscationSettings.swift19
-rw-r--r--ios/MullvadTypes/ObfuscationMethod.swift15
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj24
-rw-r--r--ios/MullvadVPN/Classes/AccessbilityIdentifier.swift1
-rw-r--r--ios/MullvadVPN/Classes/AppRoutes.swift16
-rw-r--r--ios/MullvadVPN/Containers/Root/HeaderBarView.swift4
-rw-r--r--ios/MullvadVPN/Containers/Root/RootContainerViewController.swift28
-rw-r--r--ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift33
-rw-r--r--ios/MullvadVPN/Debug/DebugCoordinator.swift45
-rw-r--r--ios/MullvadVPN/Debug/DebugView.swift109
-rw-r--r--ios/MullvadVPN/Debug/DebugViewModel.swift301
-rw-r--r--ios/MullvadVPN/Extensions/String+Localization.swift13
14 files changed, 605 insertions, 35 deletions
diff --git a/ios/Assets/Localizable.xcstrings b/ios/Assets/Localizable.xcstrings
index 277e38b01e..79bdec6c50 100644
--- a/ios/Assets/Localizable.xcstrings
+++ b/ios/Assets/Localizable.xcstrings
@@ -15054,6 +15054,10 @@
}
}
},
+ "Debug" : {
+ "comment" : "Title of the debug view.",
+ "isCommentAutoGenerated" : true
+ },
"Debug options" : {
"localizations" : {
"da" : {
diff --git a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
index 69e2a58f34..e35f049f27 100644
--- a/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
+++ b/ios/MullvadRustRuntime/include/mullvad_rust_runtime.h
@@ -746,34 +746,6 @@ struct SwiftShadowsocksLoaderWrapper init_swift_shadowsocks_loader_wrapper(const
* object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish`
* when completion finishes (in completion.finish).
*
- * `retry_strategy` must have been created by a call to either of the following functions
- * `mullvad_api_retry_strategy_never`, `mullvad_api_retry_strategy_constant` or `mullvad_api_retry_strategy_exponential`
- *
- * `account_number` must be a pointer to a null terminated string.
- *
- * `body` must be a pointer to a contiguous memory segment
- *
- * `body_size` must be the size of the body
- *
- * This function is not safe to call multiple times with the same `CompletionCookie`.
- */
-struct SwiftCancelHandle mullvad_ios_legacy_storekit_payment(struct SwiftApiContext api_context,
- void *completion_cookie,
- struct SwiftRetryStrategy retry_strategy,
- const char *account_number,
- const uint8_t *body,
- uintptr_t body_size);
-
-/**
- * # Safety
- *
- * `api_context` must be pointing to a valid instance of `SwiftApiContext`. A `SwiftApiContext` is created
- * by calling `mullvad_api_init_new`.
- *
- * This function takes ownership of `completion_cookie`, which must be pointing to a valid instance of Swift
- * object `MullvadApiCompletion`. The pointer will be freed by calling `mullvad_api_completion_finish`
- * when completion finishes (in completion.finish).
- *
* `account_number` must be a pointer to a null terminated string.
*
* `retry_strategy` must have been created by a call to either of the following functions
diff --git a/ios/MullvadSettings/WireGuardObfuscationSettings.swift b/ios/MullvadSettings/WireGuardObfuscationSettings.swift
index abc323ff92..6e561a7f72 100644
--- a/ios/MullvadSettings/WireGuardObfuscationSettings.swift
+++ b/ios/MullvadSettings/WireGuardObfuscationSettings.swift
@@ -12,7 +12,7 @@ import MullvadTypes
/// Whether obfuscation is enabled and which method is used.
///
/// `.automatic` means an algorithm will decide whether to use obfuscation or not.
-public enum WireGuardObfuscationState: Codable, Sendable {
+public enum WireGuardObfuscationState: CustomStringConvertible, Codable, Sendable {
@available(*, deprecated, renamed: "udpOverTcp")
case on
@@ -54,6 +54,23 @@ public enum WireGuardObfuscationState: Codable, Sendable {
public var isEnabled: Bool {
[.udpOverTcp, .shadowsocks, .quic].contains(self)
}
+
+ public var description: String {
+ switch self {
+ case .automatic:
+ "Automatic"
+ case .on:
+ "On"
+ case .udpOverTcp:
+ "UDP over TCP"
+ case .shadowsocks:
+ "Shadowsocks"
+ case .quic:
+ "QUIC"
+ case .off:
+ "Off"
+ }
+ }
}
public enum WireGuardObfuscationUdpOverTcpPort: Codable, Equatable, CustomStringConvertible, Sendable {
diff --git a/ios/MullvadTypes/ObfuscationMethod.swift b/ios/MullvadTypes/ObfuscationMethod.swift
index 87d02cf78e..d577de336d 100644
--- a/ios/MullvadTypes/ObfuscationMethod.swift
+++ b/ios/MullvadTypes/ObfuscationMethod.swift
@@ -9,7 +9,7 @@
import Foundation
/// Describes the resolved obfuscation method with all required parameters.
-public enum ObfuscationMethod: Equatable, Codable, Sendable {
+public enum ObfuscationMethod: CustomStringConvertible, Equatable, Codable, Sendable {
case off
case udpOverTcp
case shadowsocks
@@ -23,4 +23,17 @@ public enum ObfuscationMethod: Equatable, Codable, Sendable {
true
}
}
+
+ public var description: String {
+ switch self {
+ case .off:
+ "Off"
+ case .udpOverTcp:
+ "UDP over TCP"
+ case .shadowsocks:
+ "Shadowsocks"
+ case .quic:
+ "QUIC"
+ }
+ }
}
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index f9c6214556..f7ca82750d 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -478,6 +478,10 @@
7A3AD5012C1068A800E9AD90 /* RelayPicking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3AD5002C1068A800E9AD90 /* RelayPicking.swift */; };
7A3FD1B52AD4465A0042BEA6 /* AppMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */; };
7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42DEC82A05164100B209BE /* SettingsInputCell.swift */; };
+ 7A42EBF42F518BE2001B8B40 /* DebugViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */; };
+ 7A42EBF62F518DE1001B8B40 /* DebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF52F518DDC001B8B40 /* DebugView.swift */; };
+ 7A42EBF82F51A0E3001B8B40 /* DebugCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */; };
+ 7A42EBFA2F51EC94001B8B40 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */; };
7A45CFC62C05FF6A00D80B21 /* ScreenshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A45CFC22C05FF2F00D80B21 /* ScreenshotTests.swift */; };
7A45CFC72C071DD400D80B21 /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58D0C79D23F1CEBA00FE9BA7 /* SnapshotHelper.swift */; };
7A460F9D2F34E2FC005A265D /* Bundle+ProductVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A460F9C2F34E2FC005A265D /* Bundle+ProductVersion.swift */; };
@@ -2060,6 +2064,10 @@
7A3AD5002C1068A800E9AD90 /* RelayPicking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPicking.swift; sourceTree = "<group>"; };
7A3FD1B42AD4465A0042BEA6 /* AppMessageHandlerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppMessageHandlerTests.swift; sourceTree = "<group>"; };
7A42DEC82A05164100B209BE /* SettingsInputCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsInputCell.swift; sourceTree = "<group>"; };
+ 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugViewModel.swift; sourceTree = "<group>"; };
+ 7A42EBF52F518DDC001B8B40 /* DebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugView.swift; sourceTree = "<group>"; };
+ 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugCoordinator.swift; sourceTree = "<group>"; };
+ 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = "<group>"; };
7A45CFC22C05FF2F00D80B21 /* ScreenshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTests.swift; sourceTree = "<group>"; };
7A460F9C2F34E2FC005A265D /* Bundle+ProductVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+ProductVersion.swift"; sourceTree = "<group>"; };
7A460F9E2F35D04C005A265D /* NewAppVersionInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAppVersionInAppNotificationProvider.swift; sourceTree = "<group>"; };
@@ -3463,6 +3471,7 @@
7AA564472F2B680D001D1FB9 /* String+Assets.swift */,
7A09C98029D99215000C2CAC /* String+FuzzyMatch.swift */,
5807E2BF2432038B00F5FF30 /* String+Helpers.swift */,
+ 7A42EBF92F51EC8A001B8B40 /* String+Localization.swift */,
58CEB2F82AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift */,
5891BF5025E66B1E006D6FB0 /* UIBarButtonItem+KeyboardNavigation.swift */,
587CBFE222807F530028DED3 /* UIColor+Helpers.swift */,
@@ -3996,6 +4005,7 @@
583FE02829C1B079006E85F9 /* Classes */,
58C774C929AB543C003A1A56 /* Containers */,
58CAF9F22983D32200BE19F7 /* Coordinators */,
+ 7A42EBF22F518BC4001B8B40 /* Debug */,
583FE02329C1AC9F006E85F9 /* Extensions */,
F09D04B82AE94F27003D4F89 /* GeneralAPIs */,
58B26E1F2943516500D5980C /* Notifications */,
@@ -4307,6 +4317,16 @@
path = APIRequest;
sourceTree = "<group>";
};
+ 7A42EBF22F518BC4001B8B40 /* Debug */ = {
+ isa = PBXGroup;
+ children = (
+ 7A42EBF72F51A0DB001B8B40 /* DebugCoordinator.swift */,
+ 7A42EBF52F518DDC001B8B40 /* DebugView.swift */,
+ 7A42EBF32F518BD0001B8B40 /* DebugViewModel.swift */,
+ );
+ path = Debug;
+ sourceTree = "<group>";
+ };
7A45CFCD2C08697100D80B21 /* Screenshots */ = {
isa = PBXGroup;
children = (
@@ -6398,6 +6418,7 @@
586A950C290125EE007BAF2B /* AlertPresenter.swift in Sources */,
7A9FA1422A2E3306000B728D /* CheckboxView.swift in Sources */,
F062000A2CB7EB42002E6DB9 /* CGSize+Helpers.swift in Sources */,
+ 7A42EBF82F51A0E3001B8B40 /* DebugCoordinator.swift in Sources */,
586C0D892B03D5E000E7CDD7 /* TextCellContentConfiguration+Extensions.swift in Sources */,
58C3F4F92964B08300D72515 /* MapViewController.swift in Sources */,
584D26C6270C8741004EA533 /* SettingsDNSTextCell.swift in Sources */,
@@ -6455,6 +6476,7 @@
58CEB30A2AFD584700E6E088 /* CustomCellDisclosureHandling.swift in Sources */,
58B26E22294351EA00D5980C /* InAppNotificationProvider.swift in Sources */,
7A9CCCB82A96302800DD6A34 /* SetupAccountCompletedCoordinator.swift in Sources */,
+ 7A42EBF62F518DE1001B8B40 /* DebugView.swift in Sources */,
F09C97232D3122F300ADE747 /* ChangeLogReader.swift in Sources */,
447F3D8B2CDE1853006E3462 /* ShadowsocksObfuscationSettingsView.swift in Sources */,
58BFA5C622A7C97F00A6173D /* RelayCacheTracker.swift in Sources */,
@@ -6564,6 +6586,7 @@
F0EF50D52A949F8E0031E8DF /* ChangeLogViewModel.swift in Sources */,
F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */,
7A6000F62B60092F001CF0D9 /* AccessMethodViewModelEditing.swift in Sources */,
+ 7A42EBFA2F51EC94001B8B40 /* String+Localization.swift in Sources */,
58EF87572B16330B00C098B2 /* ProxyConfigurationTester.swift in Sources */,
5827B0A62B0F39E900CCBBA1 /* EditAccessMethodInteractor.swift in Sources */,
5827B0AE2B0F4CBE00CCBBA1 /* MethodSettingsViewControllerDelegate.swift in Sources */,
@@ -6719,6 +6742,7 @@
44E1F7582D3EA83A003A60FF /* DestinationDescriber.swift in Sources */,
58FD5BF42428C67600112C88 /* InAppPurchaseButton.swift in Sources */,
7AF10EB22ADE859200C090B9 /* AlertViewController.swift in Sources */,
+ 7A42EBF42F518BE2001B8B40 /* DebugViewModel.swift in Sources */,
587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */,
F028A56C2A34D8E600C0CAA3 /* AddCreditSucceededViewController.swift in Sources */,
58293FAE2510CA58005D0BB5 /* ProblemReportViewController.swift in Sources */,
diff --git a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
index 032025e15a..0bfbccaab9 100644
--- a/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
+++ b/ios/MullvadVPN/Classes/AccessbilityIdentifier.swift
@@ -185,6 +185,7 @@ public enum AccessibilityIdentifier: Equatable {
case notificationPromptSkipButton
case notificationPromptEnableButton
case includeAllNetworksView
+ case debugView
// Other UI elements
case accessMethodEnableSwitch
diff --git a/ios/MullvadVPN/Classes/AppRoutes.swift b/ios/MullvadVPN/Classes/AppRoutes.swift
index fbccb77554..906dec7993 100644
--- a/ios/MullvadVPN/Classes/AppRoutes.swift
+++ b/ios/MullvadVPN/Classes/AppRoutes.swift
@@ -43,12 +43,17 @@ enum AppRouteGroup: AppRouteGroupProtocol {
*/
case alert(_ alertId: String)
+ /**
+ Debug group.
+ */
+ case debug
+
var isModal: Bool {
switch self {
case .primary:
return false
- case .selectLocation, .account, .settings, .changelog, .alert:
+ case .selectLocation, .account, .settings, .changelog, .alert, .debug:
return true
}
}
@@ -57,7 +62,7 @@ enum AppRouteGroup: AppRouteGroupProtocol {
switch self {
case .primary:
return 0
- case .account, .selectLocation, .changelog:
+ case .account, .selectLocation, .changelog, .debug:
return 1
case .settings:
return 2
@@ -133,6 +138,11 @@ enum AppRoute: AppRouteProtocol {
*/
case tos, login, main, revoked, outOfTime, welcome
+ /**
+ Debug view route.
+ */
+ case debug
+
var isExclusive: Bool {
switch self {
case .account, .settings, .alert:
@@ -162,6 +172,8 @@ enum AppRoute: AppRouteProtocol {
return .settings
case let .alert(id):
return .alert(id)
+ case .debug:
+ return .debug
}
}
}
diff --git a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
index adae341e5d..e7e08b066c 100644
--- a/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
+++ b/ios/MullvadVPN/Containers/Root/HeaderBarView.swift
@@ -12,8 +12,6 @@ class HeaderBarView: UIView {
private let brandNameImage = UIImage(named: "LogoText")?
.withTintColor(UIColor.HeaderBar.brandNameColor, renderingMode: .alwaysOriginal)
- private let logoImageView = UIImageView(image: UIImage(named: "LogoIcon"))
-
private lazy var brandNameImageView: UIImageView = {
let imageView = UIImageView(image: brandNameImage)
imageView.contentMode = .scaleAspectFill
@@ -61,6 +59,8 @@ class HeaderBarView: UIView {
return layer
}()
+ let logoImageView = UIImageView(image: UIImage(named: "LogoIcon"))
+
let accountButton: UIButton = {
let button = makeHeaderBarButton(with: UIImage.Buttons.account)
button.setAccessibilityIdentifier(.accountButton)
diff --git a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
index e735264a29..680e08bbaf 100644
--- a/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
+++ b/ios/MullvadVPN/Containers/Root/RootContainerViewController.swift
@@ -62,6 +62,11 @@ extension RootContainment {
}
protocol RootContainerViewControllerDelegate: AnyObject, Sendable {
+ func rootContainerViewControllerShouldShowDebugView(
+ _ controller: RootContainerViewController,
+ animated: Bool
+ )
+
func rootContainerViewControllerShouldShowAccount(
_ controller: RootContainerViewController,
animated: Bool
@@ -293,6 +298,14 @@ class RootContainerViewController: UIViewController {
updateHeaderBarHiddenFromChildPreferences(animated: UIView.areAnimationsEnabled)
}
+ /// Request to display debug view
+ func showDebugView(animated: Bool) {
+ delegate?.rootContainerViewControllerShouldShowDebugView(
+ self,
+ animated: animated
+ )
+ }
+
/// Request to display settings controller
func showAccount(animated: Bool) {
delegate?.rootContainerViewControllerShouldShowAccount(
@@ -361,6 +374,15 @@ class RootContainerViewController: UIViewController {
// Prevent automatic layout margins adjustment as we manually control them.
headerBarView.insetsLayoutMarginsFromSafeArea = false
+ #if DEBUG
+ headerBarView.logoImageView.isUserInteractionEnabled = true
+ headerBarView.logoImageView.addGestureRecognizer(
+ UITapGestureRecognizer(
+ target: self,
+ action: #selector(handleLogoTap(_:)))
+ )
+ #endif
+
headerBarView.accountButton.addTarget(
self,
action: #selector(handleAccountButtonTap),
@@ -418,6 +440,12 @@ class RootContainerViewController: UIViewController {
return button
}
+ @objc private func handleLogoTap(_ gestureRecognizer: UIGestureRecognizer) {
+ if gestureRecognizer.state == .ended {
+ showDebugView(animated: true)
+ }
+ }
+
@objc private func handleAccountButtonTap() {
showAccount(animated: true)
}
diff --git a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
index 80dbb41d5f..88fcbdfd40 100644
--- a/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
+++ b/ios/MullvadVPN/Coordinators/ApplicationCoordinator.swift
@@ -10,6 +10,7 @@ import Combine
import MullvadREST
import MullvadSettings
import MullvadTypes
+import Network
import Routing
import UIKit
@@ -173,6 +174,8 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
presentDNSSettings(animated: animated, completion: completion)
case .ipOverrides:
presentIPOverride(animated: animated, completion: completion)
+ case .debug:
+ presentDebug(animated: animated, completion: completion)
}
}
@@ -189,7 +192,7 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
completion()
context.dismissedRoutes.forEach { $0.coordinator.removeFromParent() }
- case .selectLocation, .account, .settings, .changelog, .alert:
+ case .selectLocation, .account, .settings, .changelog, .alert, .debug:
guard let coordinator = dismissedRoute.coordinator as? Presentable else {
completion()
return assertionFailure("Expected presentable coordinator for \(dismissedRoute.route)")
@@ -819,7 +822,28 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
animated: true,
configuration: ModalPresentationConfiguration(modalPresentationStyle: .automatic)
)
+ }
+
+ private func presentDebug(animated: Bool, completion: @escaping @Sendable (Coordinator) -> Void) {
+ let viewModel = DebugViewModelImpl(
+ tunnelManager: tunnelManager,
+ nwPathMonitor: NWPathMonitor(),
+ appPreferences: appPreferences
+ )
+ let coordinator = DebugCoordinator(
+ navigationController: CustomNavigationController(),
+ viewModel: viewModel
+ )
+
+ coordinator.didFinish = { [weak self] _ in
+ self?.router.dismiss(.debug, animated: true)
+ }
+ coordinator.start(animated: animated)
+
+ presentChild(coordinator, animated: animated) {
+ completion(coordinator)
+ }
}
private func addTunnelObserver() {
@@ -1082,6 +1106,13 @@ final class ApplicationCoordinator: Coordinator, Presenting, @preconcurrency Roo
// MARK: - RootContainerViewControllerDelegate
+ func rootContainerViewControllerShouldShowDebugView(
+ _ controller: RootContainerViewController,
+ animated: Bool
+ ) {
+ router.present(.debug, animated: animated)
+ }
+
func rootContainerViewControllerShouldShowAccount(
_ controller: RootContainerViewController,
animated: Bool
diff --git a/ios/MullvadVPN/Debug/DebugCoordinator.swift b/ios/MullvadVPN/Debug/DebugCoordinator.swift
new file mode 100644
index 0000000000..30299b3fc2
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugCoordinator.swift
@@ -0,0 +1,45 @@
+//
+// DebugCoordinator.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import Routing
+import SwiftUI
+
+class DebugCoordinator: Coordinator, Presentable, Presenting {
+ private let navigationController: UINavigationController
+ private let viewModel: DebugViewModelImpl
+ // private let alertPresenter: AlertPresenter
+
+ var presentedViewController: UIViewController {
+ navigationController
+ }
+
+ var didFinish: ((DebugCoordinator) -> Void)?
+
+ init(
+ navigationController: UINavigationController,
+ viewModel: DebugViewModelImpl
+ ) {
+ self.navigationController = navigationController
+ self.viewModel = viewModel
+
+ super.init()
+
+ // alertPresenter = AlertPresenter(context: self)
+ }
+
+ func start(animated: Bool) {
+ let view = DebugView(viewModel: viewModel)
+
+ let host = UIHostingController(rootView: view)
+ host.title = NSLocalizedString("Debug", comment: "")
+ host.view.setAccessibilityIdentifier(.debugView)
+ host.view.backgroundColor = .secondaryColor
+
+ navigationController.pushViewController(host, animated: animated)
+ }
+}
diff --git a/ios/MullvadVPN/Debug/DebugView.swift b/ios/MullvadVPN/Debug/DebugView.swift
new file mode 100644
index 0000000000..8de0299ca8
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugView.swift
@@ -0,0 +1,109 @@
+//
+// DebugView.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import SwiftUI
+
+@MainActor
+struct DebugView<ViewModel: DebugViewModel>: View {
+ @ObservedObject var viewModel: ViewModel
+ @State private var alert: MullvadAlert?
+
+ @State private var showRelays: Bool = false
+ @State private var showSettings: Bool = false
+
+ var body: some View {
+ ZStack {
+ ScrollView {
+ VStack(alignment: .leading) {
+ HStack {
+ Spacer()
+ }
+
+ connection()
+ settings()
+ }
+ .background(Color.mullvadBackground)
+ Spacer()
+ }
+ .padding(EdgeInsets(UIMetrics.contentLayoutMargins))
+ }
+ .mullvadAlert(item: $alert)
+ .font(.mullvadTiny)
+ .foregroundStyle(Color.mullvadTextPrimary)
+ .background(Color.mullvadBackground)
+
+ Spacer()
+ }
+}
+
+extension DebugView {
+ private func connection() -> some View {
+ VStack {
+ Button {
+ withAnimation {
+ showRelays.toggle()
+ }
+ } label: {
+ Text("Connection".excludeLocalization)
+ .font(.mullvadMedium)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ Spacer()
+ }
+
+ RowSeparator()
+
+ VStack(alignment: .leading) {
+ ForEach(Array(viewModel.connection), id: \.title) { item in
+ createSection(title: item.title, rows: item.data)
+ }
+ }
+ .showIf(showRelays)
+ }
+ }
+
+ private func settings() -> some View {
+ VStack {
+ Button {
+ withAnimation {
+ showSettings.toggle()
+ }
+ } label: {
+ Text("Settings".excludeLocalization)
+ .font(.mullvadMedium)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ Spacer()
+ }
+
+ RowSeparator()
+
+ VStack(alignment: .leading) {
+ ForEach(Array(viewModel.settings), id: \.title) { item in
+ createSection(title: item.title, rows: item.data)
+ }
+ }.showIf(showSettings)
+ }
+ }
+
+ private func createSection(title: String, rows: [String]) -> some View {
+ VStack(alignment: .leading) {
+ Text(title)
+ .font(.mullvadSmallSemiBold)
+ .foregroundStyle(Color.mullvadTextSecondary)
+ ForEach(rows, id: \.self) {
+ Text($0)
+ }
+
+ RowSeparator()
+ }
+ }
+}
+
+#Preview {
+ DebugView(viewModel: MockDebugViewModel())
+}
diff --git a/ios/MullvadVPN/Debug/DebugViewModel.swift b/ios/MullvadVPN/Debug/DebugViewModel.swift
new file mode 100644
index 0000000000..e6f4ebd1db
--- /dev/null
+++ b/ios/MullvadVPN/Debug/DebugViewModel.swift
@@ -0,0 +1,301 @@
+//
+// DebugViewModel.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+import MullvadSettings
+import Network
+import PacketTunnelCore
+import SwiftUI
+
+@MainActor
+protocol DebugViewModel: ObservableObject {
+ typealias Item = (title: String, data: [String])
+
+ var tunnelSettings: LatestTunnelSettings { get }
+ var nwPathStatus: NWPath.Status { get }
+
+ var connection: [Item] { get }
+ var settings: [Item] { get }
+}
+
+class DebugViewModelImpl: DebugViewModel {
+ var tunnelManager: TunnelManager
+ var nwPathMonitor: NWPathMonitor
+ var appPreferences: AppPreferencesDataSource
+ var tunnelObserver: TunnelBlockObserver!
+
+ var tunnelSettings: LatestTunnelSettings
+ var tunnelStatus: TunnelStatus
+ var nwPathStatus = NWPath.Status.unsatisfied
+
+ @Published var connection = [Item]()
+ @Published var settings = [Item]()
+
+ init(
+ tunnelManager: TunnelManager,
+ nwPathMonitor: NWPathMonitor,
+ appPreferences: AppPreferencesDataSource
+ ) {
+ self.tunnelManager = tunnelManager
+ self.nwPathMonitor = nwPathMonitor
+ self.appPreferences = appPreferences
+
+ tunnelSettings = tunnelManager.settings
+ tunnelStatus = tunnelManager.tunnelStatus
+
+ refreshData()
+
+ tunnelObserver = TunnelBlockObserver(
+ didUpdateTunnelStatus: { _, status in
+ self.tunnelStatus = status
+ self.refreshData()
+ },
+ didUpdateDeviceState: { _, _, _ in },
+ didUpdateTunnelSettings: { _, settings in
+ self.tunnelSettings = settings
+ self.refreshData()
+ }
+ )
+ self.tunnelManager.addObserver(tunnelObserver)
+ }
+
+ // Arrange these functions to get the desired section order in the view.
+ private func refreshData() {
+ // Connection
+ setRelays()
+ setObfuscation()
+
+ // Settings
+ setRelaySettings()
+ setMultihopSettings()
+ setDaitaSettings()
+ setObfuscationSettings()
+ setQuantumResistanceSettings()
+ setIncludeAllNetworksSettings()
+ setMullvadDnsBlockers()
+ setCustomDnsBlockers()
+ }
+
+ private func update(item: Item, in list: inout [Item]) {
+ guard let index = (list.firstIndex { $0.title == item.title }) else {
+ list.append(item)
+ return
+ }
+
+ list.remove(at: index)
+ list.insert(item, at: index)
+ }
+}
+
+// MARK: Connection
+
+extension DebugViewModelImpl {
+ private func setRelays() {
+ let entry = tunnelStatus.state.relays?.entry?.debugDescription ?? "-"
+ let exit = tunnelStatus.state.relays?.exit.debugDescription ?? "-"
+
+ update(
+ item: (
+ title: "Relays",
+ data: [
+ "Entry: \(entry)",
+ "Exit: \(exit)",
+ ]
+ ), in: &connection
+ )
+ }
+
+ private func setObfuscation() {
+ update(
+ item: (
+ title: "Obfuscation",
+ data: [
+ tunnelStatus.observedState.connectionState?.obfuscationMethod.description ?? "-"
+ ]
+ ), in: &connection
+ )
+ }
+}
+
+// MARK: Settings
+
+extension DebugViewModelImpl {
+ func setRelaySettings() {
+ let entry = tunnelSettings.relayConstraints.entryLocations.value?.locations.first?.stringRepresentation ?? "-"
+ let exit = tunnelSettings.relayConstraints.exitLocations.value?.locations.first?.stringRepresentation ?? "-"
+
+ update(
+ item: (
+ title: "Relays",
+ data: [
+ "Entry: \(entry)",
+ "Exit: \(exit)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setMullvadDnsBlockers() {
+ let blockingOptions = tunnelSettings.dnsSettings.blockingOptions
+ var dnsBlockers = [String]()
+
+ if blockingOptions.contains(.blockAdvertising) {
+ dnsBlockers.append("Advertising (\(DNSBlockingOptions.blockAdvertising.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockTracking) {
+ dnsBlockers.append("Tracking (\(DNSBlockingOptions.blockTracking.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockMalware) {
+ dnsBlockers.append("Malware (\(DNSBlockingOptions.blockMalware.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockAdultContent) {
+ dnsBlockers.append("Adult content (\(DNSBlockingOptions.blockAdultContent.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockGambling) {
+ dnsBlockers.append("Gambling (\(DNSBlockingOptions.blockGambling.serverAddress!))")
+ }
+ if blockingOptions.contains(.blockSocialMedia) {
+ dnsBlockers.append("Social media (\(DNSBlockingOptions.blockSocialMedia.serverAddress!))")
+ }
+
+ update(
+ item: (
+ title: "Mullvad DNS blockers",
+ data: dnsBlockers.isEmpty ? ["-"] : dnsBlockers
+ ), in: &settings
+ )
+ }
+
+ func setCustomDnsBlockers() {
+ let customAddresses = tunnelSettings.dnsSettings.customDNSDomains.map { $0.debugDescription }
+
+ update(
+ item: (
+ title: "Custom DNS blockers",
+ data: customAddresses.isEmpty ? ["-"] : customAddresses
+ ), in: &settings
+ )
+ }
+
+ func setObfuscationSettings() {
+ let method = tunnelSettings.wireGuardObfuscation.state.description
+ let udpTcpPort = tunnelSettings.wireGuardObfuscation.udpOverTcpPort.description
+ let shadowSocksPort = tunnelSettings.wireGuardObfuscation.shadowsocksPort.description
+
+ update(
+ item: (
+ title: "Obfuscation",
+ data: [
+ "Method: \(method)",
+ "UDP over TCP port: \(udpTcpPort)",
+ "Shadowsocks port: \(shadowSocksPort)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setQuantumResistanceSettings() {
+ update(
+ item: (
+ title: "Quantum resistance",
+ data: [
+ tunnelSettings.tunnelQuantumResistance.isEnabled ? "Enabled" : "Disabled"
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setMultihopSettings() {
+ update(
+ item: (
+ title: "Multihop",
+ data: [
+ tunnelSettings.tunnelMultihopState.isEnabled ? "Enabled" : "Disabled"
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setDaitaSettings() {
+ let daitaIsEnabled = tunnelSettings.daita.daitaState.isEnabled ? "Enabled" : "Disabled"
+ let directOnlyIsEnabled = tunnelSettings.daita.directOnlyState.isEnabled ? "Enabled" : "Disabled"
+
+ update(
+ item: (
+ title: "DAITA",
+ data: [
+ "DAITA: \(daitaIsEnabled)",
+ "Direct only: \(directOnlyIsEnabled)",
+ ]
+ ), in: &settings
+ )
+ }
+
+ func setIncludeAllNetworksSettings() {
+ let includeAllNetworksIsEnabled =
+ tunnelSettings.includeAllNetworks.includeAllNetworksIsEnabled ? "Enabled" : "Disabled"
+ let localNetworkSharingIsEnabled =
+ tunnelSettings.includeAllNetworks.localNetworkSharingIsEnabled ? "Enabled" : "Disabled"
+ let consent = appPreferences.includeAllNetworksConsent ? "True" : "False"
+
+ update(
+ item: (
+ title: "Force all apps",
+ data: [
+ "Force all apps: \(includeAllNetworksIsEnabled)",
+ "Local network sharing: \(localNetworkSharingIsEnabled)",
+ "Consent: \(consent)",
+ ]
+ ), in: &settings
+ )
+ }
+}
+
+// MARK: Mock
+
+class MockDebugViewModel: DebugViewModel {
+ var tunnelSettings: LatestTunnelSettings = LatestTunnelSettings()
+ var nwPathStatus: NWPath.Status = NWPath.Status.unsatisfied
+
+ // Connection
+ var connection = [
+ (
+ title: "Relays",
+ data: [
+ "Entry: Sweden, Gothenburg, se-got-001",
+ "Exit: Sweden, Gothenburg, se-got-002",
+ ]
+ ),
+ (title: "Obfuscation", data: [""]),
+ ]
+
+ // Settings
+ var settings = [
+ (title: "Relays", data: ["Entry: se-se-got-se-got-001", "Exit: se-se-got-se-got-001"]),
+ (
+ title: "Mullvad DNS blockers",
+ data: [
+ "Advertising (100.64.0.1)",
+ "Tracking (100.64.0.2)",
+ "Social Media (100.64.0.3)",
+ ]
+ ),
+ (title: "Custom DNS blockers", data: ["192.168.1.1", "192.168.1.2"]),
+ (title: "Obfuscation", data: ["QUIC", "UDP over TCP port: 53", "Shadowsocks port: 53"]),
+ (title: "Quantum resistance", data: ["Enabled"]),
+ (title: "Multihop", data: ["Disabled"]),
+ (title: "DAITA", data: ["DAITA: Enabled", "Direct only: Disabled"]),
+ (
+ title: "Force all apps",
+ data: [
+ "Force all apps: Disabled",
+ "Local network sharing: Disabled",
+ "Consent: False",
+ ]
+ ),
+ ]
+}
diff --git a/ios/MullvadVPN/Extensions/String+Localization.swift b/ios/MullvadVPN/Extensions/String+Localization.swift
new file mode 100644
index 0000000000..766f61a146
--- /dev/null
+++ b/ios/MullvadVPN/Extensions/String+Localization.swift
@@ -0,0 +1,13 @@
+//
+// String+Localization.swift
+// MullvadVPN
+//
+// Created by Jon Petersson on 2026-02-27.
+// Copyright © 2026 Mullvad VPN AB. All rights reserved.
+//
+
+extension String {
+ var excludeLocalization: String {
+ String(self)
+ }
+}