Repository: govuk-one-login/mobile-ios-one-login-app
Pull request #311: feat: caching app info
Author: login: Magdaline-A
Created: 2025-04-15T07:38:39Z | Merged: 2025-04-16T15:49:14Z

# DCMAW-12406: Caching behaviour for /appInfo JSON

Adding behaviour for cached app info so that the app may still be available if the network call fails.

### QA Evidence
**AC 1:** App cached /appInfo json response in persistent memory
- See unit test [here](https://github.com/govuk-one-login/mobile-ios-one-login-app/pull/311/files#diff-57d91cae51faa6462386bf518e444fade72f953606d3a0fde64b3bd78ed3c834R128)

**AC 2:** /appInfo call fails (json available)
- See unit test [here](https://github.com/govuk-one-login/mobile-ios-one-login-app/pull/311/files#diff-57d91cae51faa6462386bf518e444fade72f953606d3a0fde64b3bd78ed3c834R114)

**AC 3:**  /appInfo call fails (json unavailable)
- See unit test [here](https://github.com/govuk-one-login/mobile-ios-one-login-app/pull/311/files#diff-57d91cae51faa6462386bf518e444fade72f953606d3a0fde64b3bd78ed3c834R124) and [here](https://github.com/govuk-one-login/mobile-ios-one-login-app/pull/311/files#diff-5b661bc566971a206f5cffa7a95b44fbd199b4f451065b443996173b3504b37bR129)

# Checklist

## Before raising your pull request:
- [x] Commit messages that conform to conventional commit messages
- [x] Ran the app locally ensuring it builds 
- [x] Ran the tests locally ensuring they pass on Build
- [x] Pull request has a clear title with a short description about the feature or update
~- [ ] Created a `draft` pull request if it is not yet ready for review~

## Before your pull request can be reviewed:
- [x] Met all of the acceptance criteria specified in the user story on Jira
- [x] Reviewed your own code to ensure you are following the style guidelines
- [x] Ran the app and tested the feature on a range of device sizes
      Please include iPod Touch, iPhone SE and iPhone 11 as a minimum.
- [x] Written Unit and Integration tests if needed

~- [ ] Met all accessibility requirements?
    - [ ] Checked dynamic type sizes are applied
    - [ ] Checked VoiceOver can navigate your new code
    - [ ] Checked a user can navigate only using a keyboard around your new code~ 

## Before merging your pull request:
- [x] Ensure that the code coverage and SonarCloud checks have passed
- [x] Actioned and resolved all comments, reaching out to reviewers for clarifications if necessary.
- [x] Ran the app to ensure that no regressions have been caused by changes during code review.



Diff:
diff --git a/MobilePlatformServices/Sources/MobilePlatformServices/App Info/AppInformationProvider.swift b/MobilePlatformServices/Sources/MobilePlatformServices/App Info/AppInformationProvider.swift
index 1c5da282a..ddecaf6fd 100644
--- a/MobilePlatformServices/Sources/MobilePlatformServices/App Info/AppInformationProvider.swift	
+++ b/MobilePlatformServices/Sources/MobilePlatformServices/App Info/AppInformationProvider.swift	
@@ -6,10 +6,15 @@ public protocol AppInformationProvider {
     var currentVersion: Version { get }
 }
 
+public enum AppInfoError: Error {
+    case invalidResponse
+}
+
 public final class AppInformationService: AppInformationProvider {
     private let client: NetworkClient
     private let baseURL: URL
-
+    private let cache: DefaultsCache
+    
     /// Initialise a new `AppInformationService`
     ///
     /// - Parameter baseURL: the host of the AppInformationService API
@@ -17,9 +22,10 @@ public final class AppInformationService: AppInformationProvider {
         self.init(client: .init(), baseURL: baseURL)
     }
     
-    init(client: NetworkClient, baseURL: URL) {
+    init(client: NetworkClient, baseURL: URL, cache: DefaultsCache = UserDefaults.standard) {
         self.client = client
         self.baseURL = baseURL
+        self.cache = cache
     }
     
     public var currentVersion: Version {
@@ -32,9 +38,22 @@ public final class AppInformationService: AppInformationProvider {
         var request = URLRequest(url: baseURL)
         request.httpMethod = "GET"
         
-        let data = try await client.makeRequest(request)
-        let appInfo = try parseResult(data).appList.iOS
+        do {
+            let data = try await client.makeRequest(request)
+            cache.set(data, forKey: "appInfoResponse")
+            let appInfo = try parseResult(data).appList.iOS
+            return appInfo
+        } catch {
+            return try loadFromDefaults()
+        }
+    }
+    
+    private func loadFromDefaults() throws -> App {
+        guard let cachedResponse = cache.data(forKey: "appInfoResponse") else {
+            throw AppInfoError.invalidResponse
+        }
         
+        let appInfo = try parseResult(cachedResponse).appList.iOS
         return appInfo
     }
     
diff --git a/MobilePlatformServices/Sources/MobilePlatformServices/App Info/DefaultsCache.swift b/MobilePlatformServices/Sources/MobilePlatformServices/App Info/DefaultsCache.swift
new file mode 100644
index 000000000..6bedaf263
--- /dev/null
+++ b/MobilePlatformServices/Sources/MobilePlatformServices/App Info/DefaultsCache.swift	
@@ -0,0 +1,8 @@
+import Foundation
+
+protocol DefaultsCache {
+    func set(_ value: Any?, forKey defaultName: String)
+    func data(forKey key: String) -> Data?
+}
+
+extension UserDefaults: DefaultsCache { }
diff --git a/MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift b/MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
index ac3b4e678..34e451a51 100644
--- a/MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
+++ b/MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
@@ -8,6 +8,7 @@ final class AppInformationServiceTests: XCTestCase {
     private var sut: AppInformationService!
     private var configuration: URLSessionConfiguration!
     private var client: NetworkClient!
+    private var mockCache: MockAppInfoCache!
     private func createMock(available: Bool = true,
                             minimumVersion: String = "1.0.0",
                             releaseFlags: [Bool] = [true, false]) -> Data {
@@ -44,9 +45,10 @@ final class AppInformationServiceTests: XCTestCase {
         configuration = URLSessionConfiguration.ephemeral
         configuration.protocolClasses = [MockURLProtocol.self]
         client = NetworkClient(configuration: configuration)
+        mockCache = MockAppInfoCache()
 
         let url = URL(string: "https://mobile.build.account.gov.uk/appInfo")!
-        sut = .init(client: client, baseURL: url)
+        sut = .init(client: client, baseURL: url, cache: mockCache)
     }
     
     override func tearDown() {
@@ -54,6 +56,7 @@ final class AppInformationServiceTests: XCTestCase {
         MockURLProtocol.clear()
         client = nil
         sut = nil
+        mockCache = nil
         
         super.tearDown()
     }
@@ -110,4 +113,48 @@ extension AppInformationServiceTests {
         XCTAssertEqual(appInfo.releaseFlags, ["Feature1": true,
                                               "Feature2": false])
     }
+    
+    func test_fetchAppVersion_usesCachedInfo() async throws {
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        MockURLProtocol.handler = {
+            (Data(), HTTPURLResponse(statusCode: 400))
+        }
+        let appInfo = try await sut.fetchAppInfo()
+        
+        XCTAssertEqual(appInfo.minimumVersion, Version(1, 0, 0))
+    }
+    
+    func test_fetchAppVersion_updatesCache() async throws {
+        // GIVEN there's already cached app info data
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        let data = try XCTUnwrap(mockCache.data(forKey: "appInfoResponse"))
+        let appInfo = try? JSONDecoder().decode(AppInfoResponse.self, from: data)
+        
+        XCTAssertTrue(appInfo?.appList.iOS.minimumVersion == Version(1, 0, 0))
+        
+        // WHEN a network call is made and new data returned
+        let newMock = createMock(available: true, minimumVersion: "2.0.0")
+        MockURLProtocol.handler = {
+            (newMock, HTTPURLResponse(statusCode: 200))
+        }
+        
+        _ = try await sut.fetchAppInfo()
+        let newCachedData = try XCTUnwrap(mockCache.data(forKey: "appInfoResponse"))
+        let newAppInfo = try? JSONDecoder().decode(AppInfoResponse.self, from: newCachedData)
+        
+        // THEN the cached data will be updated
+        XCTAssertTrue(newAppInfo?.appList.iOS.minimumVersion == Version(2, 0, 0))
+    }
+    
+    func test_fetchAppInfoError() async throws {
+        MockURLProtocol.handler = {
+            (Data(), HTTPURLResponse(statusCode: 400))
+        }
+        
+        do {
+            _ = try await sut.fetchAppInfo()
+        } catch let error as AppInfoError {
+            XCTAssertTrue(error == .invalidResponse)
+        }
+    }
 }
diff --git a/MobilePlatformServices/Tests/Mocks/MockAppInfoCache.swift b/MobilePlatformServices/Tests/Mocks/MockAppInfoCache.swift
new file mode 100644
index 000000000..9b1d09ba3
--- /dev/null
+++ b/MobilePlatformServices/Tests/Mocks/MockAppInfoCache.swift
@@ -0,0 +1,14 @@
+import Foundation
+@testable import MobilePlatformServices
+
+class MockAppInfoCache: DefaultsCache {
+    var savedData = [String: Any]()
+    
+    func set(_ value: Any?, forKey defaultName: String) {
+        savedData[defaultName] = value
+    }
+    
+    func data(forKey key: String) -> Data? {
+        savedData[key] as? Data
+    }
+}
diff --git a/Sources/Qualification/State/AppQualifyingService.swift b/Sources/Qualification/State/AppQualifyingService.swift
index e5c6ab584..dc2f21da7 100644
--- a/Sources/Qualification/State/AppQualifyingService.swift
+++ b/Sources/Qualification/State/AppQualifyingService.swift
@@ -74,6 +74,8 @@ final class AppQualifyingService: QualifyingService {
             }
             
             appInfoState = .qualified
+        } catch AppInfoError.invalidResponse {
+            appInfoState = .unavailable
         } catch URLError.notConnectedToInternet {
             appInfoState = .offline
         } catch {
diff --git a/Tests/UnitTests/Mocks/Sessions+Services/MockAppInfoService.swift b/Tests/UnitTests/Mocks/Sessions+Services/MockAppInfoService.swift
index a0db968b4..aef9c9948 100644
--- a/Tests/UnitTests/Mocks/Sessions+Services/MockAppInfoService.swift
+++ b/Tests/UnitTests/Mocks/Sessions+Services/MockAppInfoService.swift
@@ -8,23 +8,18 @@ final class MockAppInformationService: AppInformationProvider {
     var currentVersion: Networking.Version = .init(.max, .max, .max)
     var allowAppUsage = true
     var didCallFetchAppInfo = false
-    var shouldReturnError = false
     
-    var errorToThrow: Error?
-
+    var errorFromFetchAppInfo: Error?
+    
     var releaseFlags: [String: Bool] = [:]
     var featureFlags: [String: Bool] = [:]
-
+    
     func fetchAppInfo() async throws -> App {
         defer {
             didCallFetchAppInfo = true
         }
-        
-        if shouldReturnError {
-            guard let errorToThrow else {
-                throw URLError(.notConnectedToInternet)
-            }
-            throw errorToThrow
+        if let errorFromFetchAppInfo {
+            throw errorFromFetchAppInfo
         }
         
         return App(minimumVersion: .init(1, 2, 0),
diff --git a/Tests/UnitTests/Qualification/AppQualifyingServiceTests.swift b/Tests/UnitTests/Qualification/AppQualifyingServiceTests.swift
index 4129349af..bf0b9bbbe 100644
--- a/Tests/UnitTests/Qualification/AppQualifyingServiceTests.swift
+++ b/Tests/UnitTests/Qualification/AppQualifyingServiceTests.swift
@@ -1,3 +1,4 @@
+import MobilePlatformServices
 @testable import OneLogin
 import SecureStore
 import XCTest
@@ -98,7 +99,7 @@ extension AppQualifyingServiceTests {
 
     func test_offlineApp_setsStateCorrectly() {
         // GIVEN the app is offline
-        appInformationProvider.shouldReturnError = true
+        appInformationProvider.errorFromFetchAppInfo = URLError(.notConnectedToInternet)
 
         sut.delegate = self
         sut.initiate()
@@ -112,8 +113,7 @@ extension AppQualifyingServiceTests {
 
     func test_errorThrown_setsStateCorrectly() {
         // GIVEN `appInfo` cannot be accessed
-        appInformationProvider.shouldReturnError = true
-        appInformationProvider.errorToThrow = URLError(.timedOut)
+        appInformationProvider.errorFromFetchAppInfo = URLError(.timedOut)
 
         sut.delegate = self
         sut.initiate()
@@ -124,6 +124,18 @@ extension AppQualifyingServiceTests {
             timeout: 5
         )
     }
+    
+    func test_appInfoError_setsStateCorrectly() {
+        appInformationProvider.errorFromFetchAppInfo = AppInfoError.invalidResponse
+        
+        sut.delegate = self
+        sut.initiate()
+        
+        waitForTruth(
+            self.appState == .unavailable,
+            timeout: 5
+        )
+    }
 }
 
 // MARK: - User State Evaluation
@@ -246,7 +258,7 @@ extension AppQualifyingServiceTests {
 // MARK: - Subscription Tests
 extension AppQualifyingServiceTests {
     func test_enrolmentComplete_changesUserState() {
-        appInformationProvider.shouldReturnError = true
+        appInformationProvider.errorFromFetchAppInfo = AppInfoError.invalidResponse
         sut.delegate = self
         sut.initiate()
 
@@ -255,7 +267,7 @@ extension AppQualifyingServiceTests {
     }
 
     func test_sessionExpiry_changesUserState() {
-        appInformationProvider.shouldReturnError = true
+        appInformationProvider.errorFromFetchAppInfo = AppInfoError.invalidResponse
         sut.delegate = self
         sut.initiate()
 
@@ -264,7 +276,7 @@ extension AppQualifyingServiceTests {
     }
 
     func test_logOut_changesUserState() {
-        appInformationProvider.shouldReturnError = true
+        appInformationProvider.errorFromFetchAppInfo = AppInfoError.invalidResponse
         sut.delegate = self
         sut.initiate()
         


Reviews:
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:10:49Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:12:33Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T09:13:59Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:47:59Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:48:02Z
user: login: dylanjames-dd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T15:48:03Z
user: login: dylanjames-dd
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T08:27:45Z
user: login: gabby-san-digco
state: APPROVED
author_association: NONE
submitted_at: 2025-04-16T11:44:12Z

Review comments:
diff_hunk: @@ -6,20 +6,26 @@ public protocol AppInformationProvider {
     var currentVersion: Version { get }
 }
 
+public enum AppInfoError: Error {
+    case invalidResponse
+}
+
 public final class AppInformationService: AppInformationProvider {
     private let client: NetworkClient
     private let baseURL: URL
-
+    private(set) var cache: UserDefaults
+    
     /// Initialise a new `AppInformationService`
     ///
     /// - Parameter baseURL: the host of the AppInformationService API
     public convenience init(baseURL: URL) {
         self.init(client: .init(), baseURL: baseURL)
     }
     
-    init(client: NetworkClient, baseURL: URL) {
+    init(client: NetworkClient, baseURL: URL, cache: UserDefaults = .standard) {
path: MobilePlatformServices/Sources/MobilePlatformServices/App Info/AppInformationProvider.swift
user: login: dylanjames-dd
body: Doesn't look like we've got a mock for this in your test file
created_at: 2025-04-15T09:10:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,4 @@
+@testable import MobilePlatformServices
path: Tests/UnitTests/Qualification/AppQualifyingServiceTests.swift
user: login: dylanjames-dd
body: Shouldn't need to import this as `@testable`
created_at: 2025-04-15T09:12:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,22 +14,22 @@ final class MockAppInformationService: AppInformationProvider {
 
     var releaseFlags: [String: Bool] = [:]
     var featureFlags: [String: Bool] = [:]
-
+    
     func fetchAppInfo() async throws -> App {
-        defer {
-            didCallFetchAppInfo = true
-        }
-        
-        if shouldReturnError {
-            guard let errorToThrow else {
-                throw URLError(.notConnectedToInternet)
+            defer {
+                didCallFetchAppInfo = true
+            }
+            
+            if shouldReturnError {
+                guard let errorToThrow else {
+                    throw URLError(.notConnectedToInternet)
+                }
+                throw errorToThrow
path: Tests/UnitTests/Mocks/Sessions+Services/MockAppInfoService.swift
user: login: dylanjames-dd
body: ```suggestion
            if let errorFromFetchAppInfo {
                throw errorFromFetchAppInfo
```
created_at: 2025-04-15T09:13:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,4 +112,51 @@ extension AppInformationServiceTests {
         XCTAssertEqual(appInfo.releaseFlags, ["Feature1": true,
                                               "Feature2": false])
     }
+    
+    func test_fetchAppVersion_usesCachedInfo() async throws {
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        MockURLProtocol.handler = {
+            (Data(), HTTPURLResponse(statusCode: 400))
+        }
+        
+        let appInfo = try await sut.fetchAppInfo()
+        
+        XCTAssertNotNil(mockCache)
+        XCTAssertEqual(appInfo.minimumVersion, Version(1, 0, 0))
+    }
+    
+    func test_fetchAppVersion_updatesCache() async throws {
+        // GIVEN there's already cached app info data
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        let data = mockCache.data(forKey: "appInfoResponse") ?? Data("".utf8)
+        let appInfo = try? JSONDecoder().decode(AppInfoResponse.self, from: data)
+        
+        XCTAssertTrue(appInfo?.appList.iOS.minimumVersion == Version(1, 0, 0))
+        
+        // WHEN a network call is made and new data returned
+        let newMock = createMock(available: true, minimumVersion: "2.0.0")
+        MockURLProtocol.handler = {
+            (newMock, HTTPURLResponse(statusCode: 200))
+        }
+        
+        _ = try await sut.fetchAppInfo()
+        let newCachedData = mockCache.data(forKey: "appInfoResponse") ?? Data("".utf8)
+        let newAppInfo = try? JSONDecoder().decode(AppInfoResponse.self, from: newCachedData)
+        
+        // THEN the cached data will be updated
+        XCTAssertTrue(newAppInfo?.appList.iOS.minimumVersion == Version(2, 0, 0))
+    }
+    
+    func test_fetchAppInfoError() async throws {
+        try mockCache.delete()
path: MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
user: login: dylanjames-dd
body: I think we probably just want to `mockCache = nil` in the `tearDown()` rather than bothering with delete being a method on the mock
created_at: 2025-04-15T15:47:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,4 +112,51 @@ extension AppInformationServiceTests {
         XCTAssertEqual(appInfo.releaseFlags, ["Feature1": true,
                                               "Feature2": false])
     }
+    
+    func test_fetchAppVersion_usesCachedInfo() async throws {
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        MockURLProtocol.handler = {
+            (Data(), HTTPURLResponse(statusCode: 400))
+        }
+        
+        let appInfo = try await sut.fetchAppInfo()
+        
+        XCTAssertNotNil(mockCache)
+        XCTAssertEqual(appInfo.minimumVersion, Version(1, 0, 0))
+    }
+    
+    func test_fetchAppVersion_updatesCache() async throws {
+        // GIVEN there's already cached app info data
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        let data = mockCache.data(forKey: "appInfoResponse") ?? Data("".utf8)
+        let appInfo = try? JSONDecoder().decode(AppInfoResponse.self, from: data)
+        
+        XCTAssertTrue(appInfo?.appList.iOS.minimumVersion == Version(1, 0, 0))
+        
+        // WHEN a network call is made and new data returned
+        let newMock = createMock(available: true, minimumVersion: "2.0.0")
+        MockURLProtocol.handler = {
+            (newMock, HTTPURLResponse(statusCode: 200))
+        }
+        
+        _ = try await sut.fetchAppInfo()
+        let newCachedData = mockCache.data(forKey: "appInfoResponse") ?? Data("".utf8)
path: MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
user: login: dylanjames-dd
body: Where we've got nil coalescing for our Data object here and on line 131 it's better, in XCTest terms, to do something like:
```swift
let newCachedData = try XCTUnwrap(mockCache.data(forKey: "appInfoResponse"))
```
which will fail if nil is returned and give you a better error than relying on JSONDecoder
created_at: 2025-04-15T15:48:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -110,4 +112,51 @@ extension AppInformationServiceTests {
         XCTAssertEqual(appInfo.releaseFlags, ["Feature1": true,
                                               "Feature2": false])
     }
+    
+    func test_fetchAppVersion_usesCachedInfo() async throws {
+        mockCache.set(createMock(), forKey: "appInfoResponse")
+        MockURLProtocol.handler = {
+            (Data(), HTTPURLResponse(statusCode: 400))
+        }
+        
+        let appInfo = try await sut.fetchAppInfo()
+        
+        XCTAssertNotNil(mockCache)
path: MobilePlatformServices/Tests/MobilePlatformServices/AppInformationServiceTests.swift
user: login: dylanjames-dd
body: I don't think this is meaningful assert, it just makes sure the property isn't nil, but it's a non-optional property anyway.
created_at: 2025-04-15T15:48:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-15T16:06:11Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=di-mobile-ios-onelogin-app&pullRequest=311) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=di-mobile-ios-onelogin-app&pullRequest=311&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=di-mobile-ios-onelogin-app&pullRequest=311&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=di-mobile-ios-onelogin-app&pullRequest=311&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=di-mobile-ios-onelogin-app&pullRequest=311&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=di-mobile-ios-onelogin-app&pullRequest=311&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=di-mobile-ios-onelogin-app&pullRequest=311)


author_association: NONE
user: login: co-eoin
created_at: 2025-04-16T15:42:59Z
body: QA Reviewed & Approved
author_association: NONE