Repository: smileidentity/ios
Pull request #310: Metadata: Proxy and VPN Detection
Author: login: tobitech
Labels: name: Review effort 3/5
Created: 2025-04-02T13:55:45Z | Merged: 2025-04-04T12:29:21Z

### **User description**
Story: https://app.shortcut.com/smileid/story/15152/

## Summary

* Add new metadata keys for proxy and vpn
* Add methods to detect proxy and vpn connection status to NetworkMetadataProvider
* Add info about proxy and vpn to the collected metadata.
* Move metadata files group into SDK classes level.

## Known Issues

N/A.

## Test Instructions

Test proxy detection
* Setup a proxy on your device.
* You can download Charles Proxy (free trial available for 30 days) and follow this guide - https://docs.tealium.com/platforms/ios-swift/charles-proxy-iosdevice/ on setting it up on your iOS device
* Run any job and inspect the metadata payload that it contains data point for proxy detection.

Test VPN detection
* Download a VPN app on your iOS Device. Windscribe is available for free
* Allow the app to add VPN configuration to your iOS device and check that VPN is enabled via the iOS network control centre widget.
* Run any job and inspect that the metadata payload contains data point for vpn detection.

## Screenshot

If applicable (e.g. UI changes), add screenshots to help explain your work.


___

### **PR Type**
Enhancement


___

### **Description**
- Implement proxy and VPN detection

- Replace LocalMetadata with singleton MetadataManager

- Add network connection monitoring

- Reorganize metadata code structure


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>22 files</summary><table>
<tr>
  <td><strong>OrchestratedBiometricKycViewModel.swift</strong><dd><code>Replace LocalMetadata with MetadataManager</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-f2d3970bebf4a742e771bdaada48824d032569744a09fb76f7cddad4c3dcdcba">+3/-3</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>DocumentCaptureViewModel.swift</strong><dd><code>Update metadata handling in document capture</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-5f1d889a81bbfa82c6935961051165e59fc503e7e67abe86a41ce897ae831e83">+45/-35</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>OrchestratedDocumentVerificationViewModel.swift</strong><dd><code>Replace LocalMetadata with MetadataManager</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-21446c9903139b4f0be2f382b7ff7eb5f726cfbacca392ed231176a1a95a6ad6">+4/-6</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>DocumentCaptureScreen.swift</strong><dd><code>Remove LocalMetadata environment object</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-3a1e13ea458fd2965dc1c2896d05dd2eab91da857647478390833bd01a286e3e">+1/-5</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>OrchestratedDocumentVerificationScreen.swift</strong><dd><code>Remove LocalMetadata dependency</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-ec956604b970ddcf02a7549c63653b5712a5f684809244e9b17a5519ca1712d2">+4/-8</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>LocalStorage.swift</strong><dd><code>Update method signature to use metadata array</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-095c2effe617fc865dd8aa26430fdf52a5ad951bd0455c991e7745980d893604">+2/-2</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>TimeIntervalExtensions.swift</strong><dd><code>Add milliseconds conversion helper</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-6d4330004e3899c52c20dce517e2dea0368161f17db091cec406cf614abd5306">+7/-0</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>IPAddressProvider.swift</strong><dd><code>Add IP address detection functionality</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-a21f8fecdb4510bb6e3db112783db37bf37b52de12ecd89ed0b0b34ee90b19c1">+50/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>MetadataKey.swift</strong><dd><code>Define enum for metadata keys</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-348e0bd7263c62ab3a34305c0ebf8ddd16aeaa108999fcf2fc430a9ad78e539c">+24/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>MetadataManager.swift</strong><dd><code>Implement singleton metadata manager</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-7a5e327598875c179f51f2fe5ccf827d4327084d68598fd2ecc5d704775a8dfd">+70/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>MetadataModels.swift</strong><dd><code>Add metadata model definitions</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-9201dd4cfed8fc9346273ef040720625841a10d80bd622750f354d34139a5ce0">+20/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Metadatum.swift</strong><dd><code>Simplify Metadatum struct</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-16edd59b858b0095a740836e73f39f32ed0714d1da6d69ef23c7e245f7152ca0">+6/-0</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>NetworkMetadataProvider.swift</strong><dd><code>Add proxy and VPN detection</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-8836da93693fd63324fcd4d69fdf6dc7f60856c9a1517e59e9489e77dd9c2653">+123/-0</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>UIDeviceExtension.swift</strong><dd><code>Move device model detection to separate file</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-9c90117578272953912ceec0ed9da64dae6dd4014737275214e06d4e090704be">+45/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Metadata.swift</strong><dd><code>Remove old metadata implementation</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-cf5d5873ce7ff9892d79388760b2d13163c8d03ae6dd77c13a2bfb3e3aa9398f">+0/-282</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>ServiceRunnable.swift</strong><dd><code>Update method signatures for new metadata format</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-17cd77deae3a4536944d14630a1e8f31672727faf840e68e5500c241335abc01">+4/-4</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>SmileIDService.swift</strong><dd><code>Update service methods to use new metadata format</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-d5f82470f7438ff0fd5415fb1a36e84bb79339423448d9d5fe88942570bc31c9">+4/-4</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>EnhancedSmartSelfieViewModel.swift</strong><dd><code>Update metadata handling in selfie capture</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-72f9bb5deaeb7c97fd27a481ca9e4fdf2352d7e6dd9413c3e08c0e9df4b8aa90">+11/-23</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>SelfieSubmissionManager.swift</strong><dd><code>Update to use metadata array instead of LocalMetadata</code>&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-2ad08c5b906cbbab6ecff771c36bbe117b567a2af5a7ef43a0781e1921a87199">+5/-5</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>SelfieViewModel.swift</strong><dd><code>Update selfie view model to use MetadataManager</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-ce09d5beb9fce2c0797ee811714d6123d4694f641aec1bdaa5cfa9447eee3369">+23/-33</a>&nbsp; </td>

</tr>

<tr>
  <td><strong>OrchestratedEnhancedSelfieCaptureScreen.swift</strong><dd><code>Remove LocalMetadata dependency</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-c3abbd29467e9878a965af13c8da627c37c3d1c920b6c0eb47b08f1b4abd8cc9">+1/-2</a>&nbsp; &nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>OrchestratedSelfieCaptureScreen.swift</strong><dd><code>Remove LocalMetadata dependency</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-868eb7e592b3f617e7580cfefbf6c960827bde5fcaf6dd2f458cd9367d3f0681">+1/-4</a>&nbsp; &nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Tests</strong></td><td><details><summary>1 files</summary><table>
<tr>
  <td><strong>NetworkingMocks.swift</strong><dd><code>Update mock service to use new metadata format</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-ad323f6ad507ec4b4a9c6c6e198d4167eb855547b460901bc3a2210ba8d2b306">+2/-2</a>&nbsp; &nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Dependencies</strong></td><td><details><summary>1 files</summary><table>
<tr>
  <td><strong>Podfile</strong><dd><code>Upgrade Sentry dependency</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
  <td><a href="https://github.com/smileidentity/ios/pull/310/files#diff-29b5a1cbec50129afcbbcdb6f92d1ba7d2691f19e2fa6716590dda60134413df">+1/-1</a>&nbsp; &nbsp; &nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

___

> <details> <summary>  Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about PR-Agent usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>


Diff:
diff --git a/Example/Podfile b/Example/Podfile
index 342e26e71..32691c0a9 100644
--- a/Example/Podfile
+++ b/Example/Podfile
@@ -4,7 +4,7 @@ platform :ios, '13.0'
 target 'SmileID_Example' do
   pod 'SmileID', :path => '../'
   pod 'netfox'
-  pod 'Sentry', '~> 8.43.0'
+  pod 'Sentry', '~> 8.48.0'
   pod 'SwiftLint'
   pod 'ArkanaKeys', :path => './ArkanaKeys/ArkanaKeys/'
   pod 'ArkanaKeysInterfaces', :path => './ArkanaKeys/ArkanaKeysInterfaces/'
diff --git a/Example/Podfile.lock b/Example/Podfile.lock
index efc8c8d90..5aa208568 100644
--- a/Example/Podfile.lock
+++ b/Example/Podfile.lock
@@ -9,9 +9,9 @@ PODS:
   - FingerprintJS/SystemControl (1.5.0)
   - lottie-ios (4.4.3)
   - netfox (1.21.0)
-  - Sentry (8.43.0):
-    - Sentry/Core (= 8.43.0)
-  - Sentry/Core (8.43.0)
+  - Sentry (8.48.0):
+    - Sentry/Core (= 8.48.0)
+  - Sentry/Core (8.48.0)
   - SmileID (10.4.2):
     - FingerprintJS
     - lottie-ios (~> 4.4.2)
@@ -23,7 +23,7 @@ DEPENDENCIES:
   - ArkanaKeys (from `./ArkanaKeys/ArkanaKeys/`)
   - ArkanaKeysInterfaces (from `./ArkanaKeys/ArkanaKeysInterfaces/`)
   - netfox
-  - Sentry (~> 8.43.0)
+  - Sentry (~> 8.48.0)
   - SmileID (from `../`)
   - SwiftLint
 
@@ -50,11 +50,11 @@ SPEC CHECKSUMS:
   FingerprintJS: 96410117a394cca04d0f1e2374944c8697f2cceb
   lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418
   netfox: 9d5cc727fe7576c4c7688a2504618a156b7d44b7
-  Sentry: 532b281a53b1b45a523fd592f608956fb36e577c
+  Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
   SmileID: d65cea02b08bc293ba6324b617d7a08ab2d0348f
   SwiftLint: 3fe909719babe5537c552ee8181c0031392be933
   ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
 
-PODFILE CHECKSUM: 3d594b06532c72adf95b06a33b17603190f6e8bb
+PODFILE CHECKSUM: 0aff99f038f933ca54faf251acd82e30dc5acf1b
 
 COCOAPODS: 1.16.2
diff --git a/Sources/SmileID/Classes/Helpers/NetworkMetadataProvider.swift b/Sources/SmileID/Classes/Helpers/NetworkMetadataProvider.swift
deleted file mode 100644
index 4764e2135..000000000
--- a/Sources/SmileID/Classes/Helpers/NetworkMetadataProvider.swift
+++ /dev/null
@@ -1,45 +0,0 @@
-import Foundation
-import Network
-import Combine
-
-/// A class for determining the current network connection type (Wi-Fi, cellular, VPN, etc.)
-class NetworkMetadataProvider {
-    private let monitor: NWPathMonitor
-    /// Array tracking connection types over time.
-    private var connectionTypes: [String] = []
-
-    init() {
-        monitor = NWPathMonitor()
-        monitor.pathUpdateHandler = { [weak self] path in
-            guard let self else { return }
-            let newConnection = if path.usesInterfaceType(.wifi) {
-                "wifi"
-            } else if path.usesInterfaceType(.cellular) {
-                "cellular"
-            } else {
-                "other"
-            }
-
-            if self.connectionTypes.last != newConnection {
-                self.connectionTypes.append(newConnection)
-            }
-        }
-
-        let queue = DispatchQueue(label: "NetworkMonitor")
-        monitor.start(queue: queue)
-    }
-
-    deinit {
-        monitor.cancel()
-    }
-}
-
-extension NetworkMetadataProvider: MetadataProvider {
-    func collectMetadata() -> [MetadataKey: String] {
-        if let jsonData = try? JSONSerialization.data(withJSONObject: connectionTypes, options: []),
-           let jsonString = String(data: jsonData, encoding: .utf8) {
-            return [.networkConnection: jsonString]
-        }
-        return [.networkConnection: "unknown"]
-    }
-}
diff --git a/Sources/SmileID/Classes/Networking/Metadata/IPAddressProvider.swift b/Sources/SmileID/Classes/Metadata/IPAddressProvider.swift
similarity index 100%
rename from Sources/SmileID/Classes/Networking/Metadata/IPAddressProvider.swift
rename to Sources/SmileID/Classes/Metadata/IPAddressProvider.swift
diff --git a/Sources/SmileID/Classes/Networking/Metadata/MetadataKey.swift b/Sources/SmileID/Classes/Metadata/MetadataKey.swift
similarity index 93%
rename from Sources/SmileID/Classes/Networking/Metadata/MetadataKey.swift
rename to Sources/SmileID/Classes/Metadata/MetadataKey.swift
index 9b4cc065a..228172dc2 100644
--- a/Sources/SmileID/Classes/Networking/Metadata/MetadataKey.swift
+++ b/Sources/SmileID/Classes/Metadata/MetadataKey.swift
@@ -15,8 +15,10 @@ enum MetadataKey: String {
     case documentFrontImageOrigin = "document_front_image_origin"
     case fingerprint
     case networkConnection = "network_connection"
+    case proxyDetected = "proxy"
     case selfieCaptureDuration = "selfie_capture_duration_ms"
     case selfieImageOrigin = "selfie_image_origin"
     case sdk
     case sdkVersion = "sdk_version"
+    case vpnDetected = "vpn"
 }
diff --git a/Sources/SmileID/Classes/Networking/Metadata/MetadataManager.swift b/Sources/SmileID/Classes/Metadata/MetadataManager.swift
similarity index 100%
rename from Sources/SmileID/Classes/Networking/Metadata/MetadataManager.swift
rename to Sources/SmileID/Classes/Metadata/MetadataManager.swift
diff --git a/Sources/SmileID/Classes/Networking/Metadata/MetadataModels.swift b/Sources/SmileID/Classes/Metadata/MetadataModels.swift
similarity index 100%
rename from Sources/SmileID/Classes/Networking/Metadata/MetadataModels.swift
rename to Sources/SmileID/Classes/Metadata/MetadataModels.swift
diff --git a/Sources/SmileID/Classes/Networking/Metadata/Metadatum.swift b/Sources/SmileID/Classes/Metadata/Metadatum.swift
similarity index 100%
rename from Sources/SmileID/Classes/Networking/Metadata/Metadatum.swift
rename to Sources/SmileID/Classes/Metadata/Metadatum.swift
diff --git a/Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift b/Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift
new file mode 100644
index 000000000..1f72c16cd
--- /dev/null
+++ b/Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift
@@ -0,0 +1,123 @@
+import Foundation
+import Network
+import Combine
+
+/// A class for determining the current network connection type (Wi-Fi, cellular, VPN, etc.)
+class NetworkMetadataProvider {
+    private let monitor: NWPathMonitor
+    /// Array tracking connection types over time.
+    private var connectionTypes: [String] = []
+
+    init() {
+        monitor = NWPathMonitor()
+        monitor.pathUpdateHandler = { [weak self] path in
+            guard let self else { return }
+            let newConnection = if path.usesInterfaceType(.wifi) {
+                "wifi"
+            } else if path.usesInterfaceType(.cellular) {
+                "cellular"
+            } else {
+                "other"
+            }
+
+            if self.connectionTypes.last != newConnection {
+                self.connectionTypes.append(newConnection)
+            }
+        }
+
+        let queue = DispatchQueue(label: "NetworkMonitor")
+        monitor.start(queue: queue)
+    }
+
+    deinit {
+        monitor.cancel()
+    }
+
+    /// Checks if the system has an HTTP, HTTPS, or SOCKS proxy configured
+    private func isProxyDetected() -> Bool {
+        // Attempt to copy the proxy settings dictionary
+        guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] else {
+            return false
+        }
+
+        // Check if there is an HTTP proxy set
+        if let httpProxy = proxySettings["HTTPProxy"] as? String,
+            !httpProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is an HTTPS proxy set
+        if let httpsProxy = proxySettings["HTTPSProxy"] as? String,
+            !httpsProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is a SOCKS proxy set.
+        if let socksProxy = proxySettings["SOCKSProxy"] as? String,
+            !socksProxy.isEmpty {
+            return true
+        }
+
+        // Check for proxy enabled status.
+        if let httpEnabled = proxySettings["HTTPEnable"] as? Int,
+            httpEnabled == 1 {
+            return true
+        }
+
+        if let httpsEnabled = proxySettings["HTTPSEnable"] as? Int,
+            httpsEnabled == 1 {
+            return true
+        }
+
+        if let socksEnabled = proxySettings["SOCKSEnabled"] as? Int,
+            socksEnabled == 1 {
+            return true
+        }
+
+        // If we haven't found any enabled proxy, return false.
+        return false
+    }
+
+    /// Checks if VPN connection is active by examining network interfaces
+    /// - Returns: Boolean indicating if VPN is detected.
+    private func isVPNActive() -> Bool {
+        guard let cfDict = CFNetworkCopySystemProxySettings() else { return false }
+        let nsDict = cfDict.takeRetainedValue() as NSDictionary
+        guard let keys = nsDict["__SCOPED__"] as? NSDictionary else { return false }
+
+        let vpnInterfacePrefixes = ["tap", "tun", "ppp", "ipsec", "utun"]
+
+        if let interfaces = keys.allKeys as? [String] {
+            return interfaces.contains { interface in
+                vpnInterfacePrefixes.contains { prefix in
+                    interface.contains(prefix)
+                }
+            }
+        }
+
+        return false
+    }
+}
+
+extension NetworkMetadataProvider: MetadataProvider {
+    func collectMetadata() -> [MetadataKey: String] {
+        var metadata: [MetadataKey: String] = [:]
+
+        // Add network connection info
+        if let jsonData = try? JSONSerialization.data(withJSONObject: connectionTypes, options: []) {
+            let jsonString = String(data: jsonData, encoding: .utf8)
+            metadata[.networkConnection] = jsonString
+        } else {
+            metadata[.networkConnection] = "unknown"
+        }
+
+        // Add proxy detection info
+        let proxyDetected = isProxyDetected()
+        metadata[.proxyDetected] = proxyDetected ? "true" : "false"
+
+        // Add VPN info
+        metadata[.vpnDetected] = isVPNActive() ? "true" : "false"
+
+        return metadata
+    }
+}
diff --git a/Sources/SmileID/Classes/Networking/Metadata/UIDeviceExtension.swift b/Sources/SmileID/Classes/Metadata/UIDeviceExtension.swift
similarity index 100%
rename from Sources/SmileID/Classes/Networking/Metadata/UIDeviceExtension.swift
rename to Sources/SmileID/Classes/Metadata/UIDeviceExtension.swift


Reviews:
user: login: prfectionist[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T13:57:53Z
user: login: robin-smileid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:13:25Z
user: login: robin-smileid
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:13:51Z
user: login: robin-smileid
body: Code changes look fine to me. It makes sense to move the code to the metadata folder. Tested with Proxyman and Proton VPN and both were detected as expected. Great work @tobitech . Thanks.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T15:47:51Z
user: login: jumaallan
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-04T11:00:41Z

Review comments:
diff_hunk: @@ -0,0 +1,123 @@
+import Foundation
+import Network
+import Combine
+
+/// A class for determining the current network connection type (Wi-Fi, cellular, VPN, etc.)
+class NetworkMetadataProvider {
+    private let monitor: NWPathMonitor
+    /// Array tracking connection types over time.
+    private var connectionTypes: [String] = []
+
+    init() {
+        monitor = NWPathMonitor()
+        monitor.pathUpdateHandler = { [weak self] path in
+            guard let self else { return }
+            let newConnection = if path.usesInterfaceType(.wifi) {
+                "wifi"
+            } else if path.usesInterfaceType(.cellular) {
+                "cellular"
+            } else {
+                "other"
+            }
+
+            if self.connectionTypes.last != newConnection {
+                self.connectionTypes.append(newConnection)
+            }
+        }
+
+        let queue = DispatchQueue(label: "NetworkMonitor")
+        monitor.start(queue: queue)
+    }
+
+    deinit {
+        monitor.cancel()
+    }
+
+    /// Checks if the system has an HTTP, HTTPS, or SOCKS proxy configured
+    private func isProxyDetected() -> Bool {
+        // Attempt to copy the proxy settings dictionary
+        guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] else {
+            return false
+        }
+
+        // Check if there is an HTTP proxy set
+        if let httpProxy = proxySettings["HTTPProxy"] as? String,
+            !httpProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is an HTTPS proxy set
+        if let httpsProxy = proxySettings["HTTPSProxy"] as? String,
+            !httpsProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is a SOCKS proxy set.
+        if let socksProxy = proxySettings["SOCKSProxy"] as? String,
+            !socksProxy.isEmpty {
+            return true
+        }
+
+        // Check for proxy enabled status.
+        if let httpEnabled = proxySettings["HTTPEnable"] as? Int,
+            httpEnabled == 1 {
+            return true
+        }
+
+        if let httpsEnabled = proxySettings["HTTPSEnable"] as? Int,
+            httpsEnabled == 1 {
+            return true
+        }
+
+        if let socksEnabled = proxySettings["SOCKSEnabled"] as? Int,
+            socksEnabled == 1 {
+            return true
+        }
+
+        // If we haven't found any enabled proxy, return false.
+        return false
+    }
+
+    /// Checks if VPN connection is active by examining network interfaces
+    /// - Returns: Boolean indicating if VPN is detected.
+    private func isVPNActive() -> Bool {
+        guard let cfDict = CFNetworkCopySystemProxySettings() else { return false }
+        let nsDict = cfDict.takeRetainedValue() as NSDictionary
+        guard let keys = nsDict["__SCOPED__"] as? NSDictionary else { return false }
+
+        let vpnInterfacePrefixes = ["tap", "tun", "ppp", "ipsec", "utun", "en0"]
path: Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift
user: login: prfectionist[bot]
body: **Suggestion:** The VPN detection logic incorrectly includes "en0" as a VPN interface prefix. "en0" is typically the Wi-Fi interface on iOS devices, not a VPN interface. Including it will cause false positives in VPN detection, as regular Wi-Fi connections will be incorrectly flagged as VPN connections. [possible issue, importance: 9]
```suggestion
        /// Checks if VPN connection is active by examining network interfaces
        /// - Returns: Boolean indicating if VPN is detected.
        private func isVPNActive() -> Bool {
            guard let cfDict = CFNetworkCopySystemProxySettings() else { return false }
            let nsDict = cfDict.takeRetainedValue() as NSDictionary
            guard let keys = nsDict["__SCOPED__"] as? NSDictionary else { return false }

            let vpnInterfacePrefixes = ["tap", "tun", "ppp", "ipsec", "utun"]

            if let interfaces = keys.allKeys as? [String] {
                return interfaces.contains { interface in
                    vpnInterfacePrefixes.contains { prefix in
                        interface.contains(prefix)
                    }
                }
            }

            return false
        }
```
created_at: 2025-04-02T13:57:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+import Foundation
+
+func getIPAddress(useIPv4: Bool) -> String {
+    var address = ""
+    var ifaddr: UnsafeMutablePointer<ifaddrs>?
+
+    guard getifaddrs(&ifaddr) == 0 else {
+        return ""
+    }
+
+    var ptr = ifaddr
+    while ptr != nil {
+        defer { ptr = ptr?.pointee.ifa_next }
+
+        guard let interface = ptr?.pointee else {
+            return ""
+        }
+
+        let addrFamily = interface.ifa_addr.pointee.sa_family
+        if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) {
path: Sources/SmileID/Classes/Metadata/IPAddressProvider.swift
user: login: prfectionist[bot]
body: **Suggestion:** The function is missing a critical null check for `interface.ifa_addr` before dereferencing it with `pointee`. If `ifa_addr` is nil, this will cause a crash. This is a common issue in network interface code and can lead to app crashes when iterating through network interfaces. [possible issue, importance: 10]
```suggestion
        func getIPAddress(useIPv4: Bool) -> String {
            var address = ""
            var ifaddr: UnsafeMutablePointer<ifaddrs>?

            guard getifaddrs(&ifaddr) == 0 else {
                return ""
            }

            var ptr = ifaddr
            while ptr != nil {
                defer { ptr = ptr?.pointee.ifa_next }

                guard let interface = ptr?.pointee else {
                    return ""
                }
        
                guard let ifaAddr = interface.ifa_addr else {
                    continue
                }

                let addrFamily = ifaAddr.pointee.sa_family
                if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) {
                    let name = String(cString: interface.ifa_name)
                    if name == "en0" || name == "en1" || name == "pdp_ip0"
                        || name == "pdp_ip1" || name == "pdp_ip2" || name == "pdp_ip3" {
                        var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
                        getnameinfo(
                            ifaAddr,
                            socklen_t(ifaAddr.pointee.sa_len),
                            &hostname, socklen_t(hostname.count),
                            nil, socklen_t(0), NI_NUMERICHOST)
                        address = String(cString: hostname)

                        if (useIPv4 && addrFamily == UInt8(AF_INET))
                            || (!useIPv4 && addrFamily == UInt8(AF_INET6)) {
                            if !useIPv4 {
                                if let percentIndex = address.firstIndex(of: "%") {
                                    address = String(address[..<percentIndex])
                                        .uppercased()
                                } else {
                                    address = address.uppercased()
                                }
                            }
                            break
                        }
                    }
                }
            }

            freeifaddrs(ifaddr)
            return address
        }
```
created_at: 2025-04-02T13:57:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+import Foundation
+import Network
+import Combine
+
+/// A class for determining the current network connection type (Wi-Fi, cellular, VPN, etc.)
+class NetworkMetadataProvider {
+    private let monitor: NWPathMonitor
+    /// Array tracking connection types over time.
+    private var connectionTypes: [String] = []
+
+    init() {
+        monitor = NWPathMonitor()
+        monitor.pathUpdateHandler = { [weak self] path in
+            guard let self else { return }
+            let newConnection = if path.usesInterfaceType(.wifi) {
+                "wifi"
+            } else if path.usesInterfaceType(.cellular) {
+                "cellular"
+            } else {
+                "other"
+            }
+
+            if self.connectionTypes.last != newConnection {
+                self.connectionTypes.append(newConnection)
+            }
+        }
+
+        let queue = DispatchQueue(label: "NetworkMonitor")
+        monitor.start(queue: queue)
+    }
+
+    deinit {
+        monitor.cancel()
+    }
+
+    /// Checks if the system has an HTTP, HTTPS, or SOCKS proxy configured
+    private func isProxyDetected() -> Bool {
+        // Attempt to copy the proxy settings dictionary
+        guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] else {
+            return false
+        }
+
+        // Check if there is an HTTP proxy set
+        if let httpProxy = proxySettings["HTTPProxy"] as? String,
+            !httpProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is an HTTPS proxy set
+        if let httpsProxy = proxySettings["HTTPSProxy"] as? String,
+            !httpsProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is a SOCKS proxy set.
+        if let socksProxy = proxySettings["SOCKSProxy"] as? String,
+            !socksProxy.isEmpty {
+            return true
+        }
+
+        // Check for proxy enabled status.
+        if let httpEnabled = proxySettings["HTTPEnable"] as? Int,
+            httpEnabled == 1 {
+            return true
+        }
+
+        if let httpsEnabled = proxySettings["HTTPSEnable"] as? Int,
+            httpsEnabled == 1 {
+            return true
+        }
path: Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift
user: login: robin-smileid
body: These two checks are the same? Can we remove one?
created_at: 2025-04-02T15:13:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+import Foundation
+import Network
+import Combine
+
+/// A class for determining the current network connection type (Wi-Fi, cellular, VPN, etc.)
+class NetworkMetadataProvider {
+    private let monitor: NWPathMonitor
+    /// Array tracking connection types over time.
+    private var connectionTypes: [String] = []
+
+    init() {
+        monitor = NWPathMonitor()
+        monitor.pathUpdateHandler = { [weak self] path in
+            guard let self else { return }
+            let newConnection = if path.usesInterfaceType(.wifi) {
+                "wifi"
+            } else if path.usesInterfaceType(.cellular) {
+                "cellular"
+            } else {
+                "other"
+            }
+
+            if self.connectionTypes.last != newConnection {
+                self.connectionTypes.append(newConnection)
+            }
+        }
+
+        let queue = DispatchQueue(label: "NetworkMonitor")
+        monitor.start(queue: queue)
+    }
+
+    deinit {
+        monitor.cancel()
+    }
+
+    /// Checks if the system has an HTTP, HTTPS, or SOCKS proxy configured
+    private func isProxyDetected() -> Bool {
+        // Attempt to copy the proxy settings dictionary
+        guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() as? [String: Any] else {
+            return false
+        }
+
+        // Check if there is an HTTP proxy set
+        if let httpProxy = proxySettings["HTTPProxy"] as? String,
+            !httpProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is an HTTPS proxy set
+        if let httpsProxy = proxySettings["HTTPSProxy"] as? String,
+            !httpsProxy.isEmpty {
+            return true
+        }
+
+        // Check if there is a SOCKS proxy set.
+        if let socksProxy = proxySettings["SOCKSProxy"] as? String,
+            !socksProxy.isEmpty {
+            return true
+        }
+
+        // Check for proxy enabled status.
+        if let httpEnabled = proxySettings["HTTPEnable"] as? Int,
+            httpEnabled == 1 {
+            return true
+        }
+
+        if let httpsEnabled = proxySettings["HTTPSEnable"] as? Int,
+            httpsEnabled == 1 {
+            return true
+        }
path: Sources/SmileID/Classes/Metadata/NetworkMetadataProvider.swift
user: login: robin-smileid
body: Oh just saw one is http and one is https. Nevermind.
created_at: 2025-04-02T15:13:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-02T13:56:39Z
body: 
<!--
  0 failure: 
  1 warning:  The source files ...
  
  
  DangerID: danger-id-Danger;
-->


<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Warnings</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:warning:</td>
      <td>The source files were changed, but the tests remain unmodified. Consider updating or adding to the tests to match the source changes.</td>
    </tr>
  </tbody>
</table>



<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/swift">Danger Swift</a> against f232c931121e61db0fde3fffe6e37b321be3a243
</p>

author_association: NONE
user: login: prfectionist[bot]
created_at: 2025-04-02T13:56:58Z
body: ## PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

<table>
<tr><td>⏱️&nbsp;<strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr>
<tr><td>🧪&nbsp;<strong>No relevant tests</strong></td></tr>
<tr><td>🔒&nbsp;<strong>Security concerns</strong><br><br>

<strong>Sensitive information exposure:</strong><br> The implementation collects device IP addresses (both IPv4 and IPv6) and network interface information which could be considered sensitive user data. While this information is needed for proxy/VPN detection, the PR should ensure this data collection complies with privacy regulations and is disclosed to users in the app's privacy policy.</td></tr>
<tr><td>⚡&nbsp;<strong>Recommended focus areas for review</strong><br><br>

<details><summary><a href='https://github.com/smileidentity/ios/pull/310/files#diff-8836da93693fd63324fcd4d69fdf6dc7f60856c9a1517e59e9489e77dd9c2653R83-R98'><strong>VPN Detection Logic</strong></a>

The VPN detection logic relies on checking network interface names which may not be reliable across all iOS versions and VPN implementations. This approach might produce false positives or negatives.
</summary>

```swift
private func isVPNActive() -> Bool {
    guard let cfDict = CFNetworkCopySystemProxySettings() else { return false }
    let nsDict = cfDict.takeRetainedValue() as NSDictionary
    guard let keys = nsDict["__SCOPED__"] as? NSDictionary else { return false }

    let vpnInterfacePrefixes = ["tap", "tun", "ppp", "ipsec", "utun", "en0"]

    if let interfaces = keys.allKeys as? [String] {
        return interfaces.contains { interface in
            vpnInterfacePrefixes.contains { prefix in
                interface.contains(prefix)
            }
        }
    }

    return false
```

</details>

<details><summary><a href='https://github.com/smileidentity/ios/pull/310/files#diff-7a5e327598875c179f51f2fe5ccf827d4327084d68598fd2ecc5d704775a8dfdR9-R70'><strong>Thread Safety</strong></a>

The MetadataManager singleton doesn't implement any thread safety mechanisms for its mutable state (staticMetadata and providers collections), which could lead to race conditions when accessed from multiple threads.
</summary>

```swift
public class MetadataManager {
    public static let shared = MetadataManager()

    private var providers: [MetadataProvider] = []
    private var staticMetadata: [MetadataKey: String] = [:]

    private init() {
        setDefaultMetadata()
        registerDefaultProviders()
    }

    private func setDefaultMetadata() {
        addMetadata(key: .sdk, value: "iOS")
        addMetadata(key: .sdkVersion, value: SmileID.version)
        addMetadata(key: .activeLivenessVersion, value: "1.0.0")
        addMetadata(
            key: .clientIP, value: getIPAddress(useIPv4: true))
        addMetadata(
            key: .fingerprint, value: SmileID.deviceId)
        addMetadata(
            key: .deviceModel, value: UIDevice.current.modelName)
        addMetadata(
            key: .deviceOS, value: UIDevice.current.systemVersion)
    }

    private func registerDefaultProviders() {
        register(provider: NetworkMetadataProvider())
    }

    func register(provider: MetadataProvider) {
        providers.append(provider)
    }

    func addMetadata(key: MetadataKey, value: String) {
        staticMetadata[key] = value
    }

    func removeMetadata(key: MetadataKey) {
        staticMetadata.removeValue(forKey: key)
    }

    public func getDefaultMetadata() -> [Metadatum] {
        return staticMetadata.map {
            Metadatum(name: $0.key.rawValue, value: $0.value)
        }
    }

    func collectAllMetadata() -> [Metadatum] {
        var allMetadata = staticMetadata

        for provider in providers {
            let providerMetadata = provider.collectMetadata()
            for (key, value) in providerMetadata {
                allMetadata[key] = value
            }
        }

        return allMetadata.map { key, value in
            Metadatum(name: key.rawValue, value: value)
        }
    }
}
```

</details>

<details><summary><a href='https://github.com/smileidentity/ios/pull/310/files#diff-a21f8fecdb4510bb6e3db112783db37bf37b52de12ecd89ed0b0b34ee90b19c1R3-R50'><strong>Memory Management</strong></a>

The getIPAddress function uses unsafe pointers without proper error handling for all potential failure cases, which could lead to memory leaks or crashes if the system calls fail unexpectedly.
</summary>

```swift
func getIPAddress(useIPv4: Bool) -> String {
    var address = ""
    var ifaddr: UnsafeMutablePointer<ifaddrs>?

    guard getifaddrs(&ifaddr) == 0 else {
        return ""
    }

    var ptr = ifaddr
    while ptr != nil {
        defer { ptr = ptr?.pointee.ifa_next }

        guard let interface = ptr?.pointee else {
            return ""
        }

        let addrFamily = interface.ifa_addr.pointee.sa_family
        if addrFamily == UInt8(AF_INET) || addrFamily == UInt8(AF_INET6) {
            let name = String(cString: interface.ifa_name)
            if name == "en0" || name == "en1" || name == "pdp_ip0"
                || name == "pdp_ip1" || name == "pdp_ip2" || name == "pdp_ip3" {
                var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
                getnameinfo(
                    interface.ifa_addr,
                    socklen_t(interface.ifa_addr.pointee.sa_len),
                    &hostname, socklen_t(hostname.count),
                    nil, socklen_t(0), NI_NUMERICHOST)
                address = String(cString: hostname)

                if (useIPv4 && addrFamily == UInt8(AF_INET))
                    || (!useIPv4 && addrFamily == UInt8(AF_INET6)) {
                    if !useIPv4 {
                        if let percentIndex = address.firstIndex(of: "%") {
                            address = String(address[..<percentIndex])
                                .uppercased()
                        } else {
                            address = address.uppercased()
                        }
                    }
                    break
                }
            }
        }
    }

    freeifaddrs(ifaddr)
    return address
}
```

</details>

</td></tr>
</table>

author_association: CONTRIBUTOR