diff --git a/Example/SmileID.xcodeproj/project.pbxproj b/Example/SmileID.xcodeproj/project.pbxproj index ebf5b21c6..01757d569 100644 --- a/Example/SmileID.xcodeproj/project.pbxproj +++ b/Example/SmileID.xcodeproj/project.pbxproj @@ -39,7 +39,7 @@ 20B6D5EC2C21CE660023D51C /* DataStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B6D5EB2C21CE660023D51C /* DataStoreError.swift */; }; 20C360C82C454C130008DBDE /* RootViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20C360C72C454C130008DBDE /* RootViewModel.swift */; }; 20DFA0EC2C21917100AC2AE7 /* View+TextSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DFA0EB2C21917100AC2AE7 /* View+TextSelection.swift */; }; - 20F3D6F32C25F4D700B32751 /* (null) in Sources */ = {isa = PBXBuildFile; }; + 20F3D6F32C25F4D700B32751 /* BuildFile in Sources */ = {isa = PBXBuildFile; }; 20F3D6F62C25F5C100B32751 /* SmileID.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 20F3D6F42C25F5C100B32751 /* SmileID.xcdatamodeld */; }; 5829A8C02BC7429A001C1E7E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 5829A8BF2BC7429A001C1E7E /* PrivacyInfo.xcprivacy */; }; 585BE4882AC7748E0091DDD8 /* RestartableTimerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585BE4872AC7748E0091DDD8 /* RestartableTimerTest.swift */; }; @@ -48,10 +48,11 @@ 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; 620F1E982B69194900185CD2 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 620F1E972B69194900185CD2 /* AlertView.swift */; }; - 620F1E9A2B691ABB00185CD2 /* (null) in Resources */ = {isa = PBXBuildFile; }; + 620F1E9A2B691ABB00185CD2 /* BuildFile in Resources */ = {isa = PBXBuildFile; }; 624777D02B0CDC9F00952842 /* EnhancedKycWithIdInputScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624777CF2B0CDC9F00952842 /* EnhancedKycWithIdInputScreen.swift */; }; 62F6766F2B0D173600417419 /* EnhancedKycWithIdInputScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F6766E2B0D173600417419 /* EnhancedKycWithIdInputScreenViewModel.swift */; }; 62F676712B0E00E800417419 /* EnhancedKycResultDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F676702B0E00E800417419 /* EnhancedKycResultDelegate.swift */; }; + 682911036C447B7771BFFECF /* Pods_SmileID_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00CD865E9409021F98A68FCB /* Pods_SmileID_Tests.framework */; }; 6AC9802B9D1A630961B5454B /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC98436935FFEA40E632182 /* CodeScanner.swift */; }; 6AC983F056A8F9088D6CF3F7 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC982147640002B81F72DEC /* SettingsView.swift */; }; 6AC984526F49F4E8F52C7494 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC98BA00298258573CBCBD4 /* ScannerViewController.swift */; }; @@ -72,8 +73,7 @@ 91CB21A52AC10C61005AEBF5 /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91CB21A42AC10C61005AEBF5 /* NavigationBar.swift */; }; 91D9FBC42AB481FE00A8D36B /* PollingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D9FBC32AB481FE00A8D36B /* PollingTests.swift */; }; 91D9FBD52AB8AB4700A8D36B /* CalculateSignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D9FBD42AB8AB4700A8D36B /* CalculateSignatureTests.swift */; }; - C449A598C4B78EB14B493293 /* Pods_SmileID_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02FE7FAA5FCF2B4172E1B98D /* Pods_SmileID_Example.framework */; }; - D4216F3C762CB28B31699F35 /* Pods_SmileID_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F3A0FB77997B5C7D540E6873 /* Pods_SmileID_Tests.framework */; }; + A6888C0B92766926550DD3A8 /* Pods_SmileID_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C99338201884FF93283419C /* Pods_SmileID_Example.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,7 +87,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 02FE7FAA5FCF2B4172E1B98D /* Pods_SmileID_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SmileID_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 00CD865E9409021F98A68FCB /* Pods_SmileID_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SmileID_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 08F30BD5CB20E5AB9E6E211E /* Pods-SmileID_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Tests.debug.xcconfig"; path = "Target Support Files/Pods-SmileID_Tests/Pods-SmileID_Tests.debug.xcconfig"; sourceTree = ""; }; 1E59E33D2BA1E64C00D2BAD2 /* PartnerParamsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartnerParamsTests.swift; sourceTree = ""; }; 1E60ED322A29C306002695FF /* HomeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; 1E60ED332A29C306002695FF /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; @@ -120,7 +121,7 @@ 20C360C72C454C130008DBDE /* RootViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModel.swift; sourceTree = ""; }; 20DFA0EB2C21917100AC2AE7 /* View+TextSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+TextSelection.swift"; sourceTree = ""; }; 20F3D6F52C25F5C100B32751 /* SmileID.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = SmileID.xcdatamodel; sourceTree = ""; }; - 23822FF3F5838ECB320564F5 /* Pods-SmileID_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Tests.release.xcconfig"; path = "Target Support Files/Pods-SmileID_Tests/Pods-SmileID_Tests.release.xcconfig"; sourceTree = ""; }; + 573532D688419D93192B1FA9 /* Pods-SmileID_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Example.release.xcconfig"; path = "Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example.release.xcconfig"; sourceTree = ""; }; 5829A8BF2BC7429A001C1E7E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 585BE4872AC7748E0091DDD8 /* RestartableTimerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestartableTimerTest.swift; sourceTree = ""; }; 58C5F1D72B05925800A6080C /* BiometricKycWithIdInputScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricKycWithIdInputScreen.swift; sourceTree = ""; }; @@ -148,9 +149,7 @@ 6AC9893915EBA33F6984A6D9 /* DocumentSelectorViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentSelectorViewModel.swift; sourceTree = ""; }; 6AC98BA00298258573CBCBD4 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = ""; }; 6AC98BC49871655D87C7DEE3 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; - 70FFBEEAB7A2E3A33EE9EA93 /* Pods-SmileID_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Example.release.xcconfig"; path = "Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example.release.xcconfig"; sourceTree = ""; }; - 784454F0D57FB1E2742E2156 /* Pods-SmileID_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Example.debug.xcconfig"; path = "Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example.debug.xcconfig"; sourceTree = ""; }; - 821B859ACAC64E44F59427CD /* Pods-SmileID_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Tests.debug.xcconfig"; path = "Target Support Files/Pods-SmileID_Tests/Pods-SmileID_Tests.debug.xcconfig"; sourceTree = ""; }; + 7C99338201884FF93283419C /* Pods_SmileID_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SmileID_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 918321E02A52E36A00D6FB7F /* URLSessionRestServiceClientTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionRestServiceClientTests.swift; sourceTree = ""; }; 918321E12A52E36A00D6FB7F /* XCTestExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = XCTestExtension.swift; path = ../../Tests/XCTestExtension.swift; sourceTree = ""; }; 918321E32A52E36A00D6FB7F /* DependencyContainerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DependencyContainerTests.swift; sourceTree = ""; }; @@ -162,7 +161,8 @@ 94E7560A47E255DD8215C183 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 9755B6A19CF28DE212F24C83 /* SmileID.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = SmileID.podspec; path = ../SmileID.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; C8CD2E3DB817D8C6334E9240 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; - F3A0FB77997B5C7D540E6873 /* Pods_SmileID_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SmileID_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CEDFCC61E89691A1B378145A /* Pods-SmileID_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Tests.release.xcconfig"; path = "Target Support Files/Pods-SmileID_Tests/Pods-SmileID_Tests.release.xcconfig"; sourceTree = ""; }; + F346EF1ED1DF5227E7973AF9 /* Pods-SmileID_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SmileID_Example.debug.xcconfig"; path = "Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,7 +170,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C449A598C4B78EB14B493293 /* Pods_SmileID_Example.framework in Frameworks */, + A6888C0B92766926550DD3A8 /* Pods_SmileID_Example.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -178,7 +178,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - D4216F3C762CB28B31699F35 /* Pods_SmileID_Tests.framework in Frameworks */, + 682911036C447B7771BFFECF /* Pods_SmileID_Tests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -280,15 +280,6 @@ path = Helpers; sourceTree = ""; }; - 34F29B5AE452286D795FCD29 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 02FE7FAA5FCF2B4172E1B98D /* Pods_SmileID_Example.framework */, - F3A0FB77997B5C7D540E6873 /* Pods_SmileID_Tests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 58C5F1D62B05922100A6080C /* BiometricKYC */ = { isa = PBXGroup; children = ( @@ -308,7 +299,7 @@ 607FACE81AFB9204008FA782 /* Tests */, 607FACD11AFB9204008FA782 /* Products */, 828BF541E068101B2E6ED55F /* Pods */, - 34F29B5AE452286D795FCD29 /* Frameworks */, + F16927B498A45E522FEB3C95 /* Frameworks */, ); sourceTree = ""; }; @@ -415,10 +406,10 @@ 828BF541E068101B2E6ED55F /* Pods */ = { isa = PBXGroup; children = ( - 784454F0D57FB1E2742E2156 /* Pods-SmileID_Example.debug.xcconfig */, - 70FFBEEAB7A2E3A33EE9EA93 /* Pods-SmileID_Example.release.xcconfig */, - 821B859ACAC64E44F59427CD /* Pods-SmileID_Tests.debug.xcconfig */, - 23822FF3F5838ECB320564F5 /* Pods-SmileID_Tests.release.xcconfig */, + F346EF1ED1DF5227E7973AF9 /* Pods-SmileID_Example.debug.xcconfig */, + 573532D688419D93192B1FA9 /* Pods-SmileID_Example.release.xcconfig */, + 08F30BD5CB20E5AB9E6E211E /* Pods-SmileID_Tests.debug.xcconfig */, + CEDFCC61E89691A1B378145A /* Pods-SmileID_Tests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -455,6 +446,15 @@ path = ../../Tests/Mocks; sourceTree = ""; }; + F16927B498A45E522FEB3C95 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7C99338201884FF93283419C /* Pods_SmileID_Example.framework */, + 00CD865E9409021F98A68FCB /* Pods_SmileID_Tests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -462,13 +462,13 @@ isa = PBXNativeTarget; buildConfigurationList = 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "SmileID_Example" */; buildPhases = ( - 09CE3487C58D7803D9B1254B /* [CP] Check Pods Manifest.lock */, + 8512F34E77FF72CD8905BE42 /* [CP] Check Pods Manifest.lock */, 607FACCC1AFB9204008FA782 /* Sources */, 917D79282AA8024400FA6624 /* SwiftLint */, 607FACCD1AFB9204008FA782 /* Frameworks */, 607FACCE1AFB9204008FA782 /* Resources */, C0BE335FFECD4DF6892309F3 /* Upload Debug Symbols to Sentry */, - 088EDD4D204057C2E745D8BC /* [CP] Embed Pods Frameworks */, + B4E6B9DF4045B6E197C117D0 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -483,7 +483,7 @@ isa = PBXNativeTarget; buildConfigurationList = 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "SmileID_Tests" */; buildPhases = ( - FB435C9B79B1BE5E6BD677F2 /* [CP] Check Pods Manifest.lock */, + C3515D148A433E1DCDCE17DF /* [CP] Check Pods Manifest.lock */, 607FACE11AFB9204008FA782 /* Sources */, 607FACE21AFB9204008FA782 /* Frameworks */, 607FACE31AFB9204008FA782 /* Resources */, @@ -549,7 +549,7 @@ buildActionMask = 2147483647; files = ( 1EFAB3172A375265008E3C13 /* Images.xcassets in Resources */, - 620F1E9A2B691ABB00185CD2 /* (null) in Resources */, + 620F1E9A2B691ABB00185CD2 /* BuildFile in Resources */, 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */, 5829A8C02BC7429A001C1E7E /* PrivacyInfo.xcprivacy in Resources */, 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */, @@ -566,39 +566,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 088EDD4D204057C2E745D8BC /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/ArkanaKeys/ArkanaKeys.framework", - "${BUILT_PRODUCTS_DIR}/ArkanaKeysInterfaces/ArkanaKeysInterfaces.framework", - "${BUILT_PRODUCTS_DIR}/FingerprintJS/FingerprintJS.framework", - "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", - "${BUILT_PRODUCTS_DIR}/SmileID/SmileID.framework", - "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework", - "${BUILT_PRODUCTS_DIR}/lottie-ios/Lottie.framework", - "${BUILT_PRODUCTS_DIR}/netfox/netfox.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArkanaKeys.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArkanaKeysInterfaces.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FingerprintJS.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SmileID.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Lottie.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/netfox.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 09CE3487C58D7803D9B1254B /* [CP] Check Pods Manifest.lock */ = { + 8512F34E77FF72CD8905BE42 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -638,6 +606,38 @@ shellPath = /bin/sh; shellScript = "# Type a script or drag a script file from your workspace to insert its path.\ncd ../Sources\n\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; + B4E6B9DF4045B6E197C117D0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/ArkanaKeys/ArkanaKeys.framework", + "${BUILT_PRODUCTS_DIR}/ArkanaKeysInterfaces/ArkanaKeysInterfaces.framework", + "${BUILT_PRODUCTS_DIR}/FingerprintJS/FingerprintJS.framework", + "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", + "${BUILT_PRODUCTS_DIR}/SmileID/SmileID.framework", + "${BUILT_PRODUCTS_DIR}/ZIPFoundation/ZIPFoundation.framework", + "${BUILT_PRODUCTS_DIR}/lottie-ios/Lottie.framework", + "${BUILT_PRODUCTS_DIR}/netfox/netfox.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArkanaKeys.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ArkanaKeysInterfaces.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FingerprintJS.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SmileID.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZIPFoundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Lottie.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/netfox.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SmileID_Example/Pods-SmileID_Example-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; C0BE335FFECD4DF6892309F3 /* Upload Debug Symbols to Sentry */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -653,7 +653,7 @@ shellPath = /bin/sh; shellScript = "# This script is responsable to upload debug symbols and source context for Sentry.\nif which sentry-cli >/dev/null; then\nexport SENTRY_ORG=smile-identity\nexport SENTRY_PROJECT=ios\nERROR=$(sentry-cli debug-files upload --include-sources \"$DWARF_DSYM_FOLDER_PATH\" 2>&1 >/dev/null)\nif [ ! $? -eq 0 ]; then\necho \"warning: sentry-cli - $ERROR\"\nfi\nelse\necho \"warning: sentry-cli not installed, download from https://github.com/getsentry/sentry-cli/releases\"\nfi\n"; }; - FB435C9B79B1BE5E6BD677F2 /* [CP] Check Pods Manifest.lock */ = { + C3515D148A433E1DCDCE17DF /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -698,7 +698,7 @@ 1ED53F6D2A2F28590020BEFB /* SmileTextField.swift in Sources */, 91CB21A52AC10C61005AEBF5 /* NavigationBar.swift in Sources */, 1ED53F6B2A2F28590020BEFB /* ProductCell.swift in Sources */, - 20F3D6F32C25F4D700B32751 /* (null) in Sources */, + 20F3D6F32C25F4D700B32751 /* BuildFile in Sources */, 1E60ED382A29C306002695FF /* Constants.swift in Sources */, 624777D02B0CDC9F00952842 /* EnhancedKycWithIdInputScreen.swift in Sources */, 1ED53F712A2F28590020BEFB /* EnterUserIDView.swift in Sources */, @@ -885,13 +885,13 @@ }; 607FACF01AFB9204008FA782 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 784454F0D57FB1E2742E2156 /* Pods-SmileID_Example.debug.xcconfig */; + baseConfigurationReference = F346EF1ED1DF5227E7973AF9 /* Pods-SmileID_Example.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 39; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99P7YGX9Q6; @@ -901,7 +901,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "com.smileidentity.example-ios"; PRODUCT_NAME = "Smile ID"; @@ -918,13 +918,13 @@ }; 607FACF11AFB9204008FA782 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 70FFBEEAB7A2E3A33EE9EA93 /* Pods-SmileID_Example.release.xcconfig */; + baseConfigurationReference = 573532D688419D93192B1FA9 /* Pods-SmileID_Example.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 36; + CURRENT_PROJECT_VERSION = 39; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99P7YGX9Q6; @@ -934,7 +934,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "com.smileidentity.example-ios"; PRODUCT_NAME = "Smile ID"; @@ -950,7 +950,7 @@ }; 607FACF31AFB9204008FA782 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 821B859ACAC64E44F59427CD /* Pods-SmileID_Tests.debug.xcconfig */; + baseConfigurationReference = 08F30BD5CB20E5AB9E6E211E /* Pods-SmileID_Tests.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; @@ -983,7 +983,7 @@ }; 607FACF41AFB9204008FA782 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 23822FF3F5838ECB320564F5 /* Pods-SmileID_Tests.release.xcconfig */; + baseConfigurationReference = CEDFCC61E89691A1B378145A /* Pods-SmileID_Tests.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Distribution"; diff --git a/Example/SmileID/Home/HomeView.swift b/Example/SmileID/Home/HomeView.swift index 430291899..86cc62d57 100644 --- a/Example/SmileID/Home/HomeView.swift +++ b/Example/SmileID/Home/HomeView.swift @@ -54,7 +54,7 @@ struct HomeView: View { ) ProductCell( image: "smart_selfie_enroll", - name: "SmartSelfie™ Enrollment (Strict Mode)", + name: "SmartSelfie™ Enrollment (Enhanced)", onClick: { viewModel.onProductClicked() }, @@ -74,7 +74,7 @@ struct HomeView: View { ) ProductCell( image: "smart_selfie_authentication", - name: "SmartSelfie™ Authentication (Strict Mode)", + name: "SmartSelfie™ Authentication (Enhanced)", onClick: { viewModel.onProductClicked() }, diff --git a/Example/SmileID/Home/ProductCell.swift b/Example/SmileID/Home/ProductCell.swift index a25967ce2..d8283b8f9 100644 --- a/Example/SmileID/Home/ProductCell.swift +++ b/Example/SmileID/Home/ProductCell.swift @@ -46,6 +46,16 @@ struct ProductCell: View { content: { NavigationView { content() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + isPresented = false + } label: { + Text(SmileIDResourcesHelper.localizedString(for: "Action.Cancel")) + .foregroundColor(SmileID.theme.accent) + } + } + } } .environment(\.modalMode, $isPresented) } diff --git a/Example/SmileID/WelcomeScreen.swift b/Example/SmileID/WelcomeScreen.swift index 0fa83dd0c..d4ae0e37a 100644 --- a/Example/SmileID/WelcomeScreen.swift +++ b/Example/SmileID/WelcomeScreen.swift @@ -24,7 +24,7 @@ struct WelcomeScreen: View { .padding(.vertical) Text("To begin testing, you need to add a configuration from the Smile Portal") - .font(EpilogueFont.regular(with: 16)) + .font(DMSansFont.regular(with: 16)) .foregroundColor(SmileID.theme.onLight) .padding(.vertical) diff --git a/Example/Tests/FaceValidatorTests.swift b/Example/Tests/FaceValidatorTests.swift index a345a42f6..bde035c08 100644 --- a/Example/Tests/FaceValidatorTests.swift +++ b/Example/Tests/FaceValidatorTests.swift @@ -24,7 +24,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithValidFace() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), + faceQuality: 0.5, brighness: 100 ) @@ -36,7 +36,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithFaceTooSmall() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 100, height: 100), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), + faceQuality: 0.5, brighness: 100 ) @@ -48,7 +48,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithFaceTooLarge() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 250, height: 250), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), + faceQuality: 0.5, brighness: 100 ) @@ -60,7 +60,7 @@ class FaceValidatorTests: XCTestCase { func testValidWithFaceOffCentre() { let result = performValidation( faceBoundingBox: CGRect(x: 125, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), + faceQuality: 0.5, brighness: 100 ) @@ -72,8 +72,8 @@ class FaceValidatorTests: XCTestCase { func testValidateWithPoorBrightness() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.1, passed: 0.9), - brighness: 70 + faceQuality: 0.5, + brighness: 35 ) XCTAssertTrue(result.faceInBounds) @@ -81,10 +81,10 @@ class FaceValidatorTests: XCTestCase { XCTAssertEqual(result.userInstruction, .goodLight) } - func testValidateWithPoorSelfieQuality() { + func testValidateWithPoorFaceQuality() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.6, passed: 0.4), + faceQuality: 0.2, brighness: 70 ) @@ -96,7 +96,7 @@ class FaceValidatorTests: XCTestCase { func testValidateWithLivenessTask() { let result = performValidation( faceBoundingBox: CGRect(x: 65, y: 164, width: 190, height: 190), - selfieQualityData: SelfieQualityData(failed: 0.3, passed: 0.7), + faceQuality: 0.3, brighness: 100, livenessTask: .lookLeft ) @@ -111,7 +111,7 @@ class FaceValidatorTests: XCTestCase { extension FaceValidatorTests { func performValidation( faceBoundingBox: CGRect, - selfieQualityData: SelfieQualityData, + faceQuality: Float, brighness: Int, livenessTask: LivenessTask? = nil ) -> FaceValidationResult { @@ -124,7 +124,7 @@ extension FaceValidatorTests { ) faceValidator.validate( faceGeometry: faceGeometry, - selfieQuality: selfieQualityData, + faceQuality: faceQuality, brightness: brighness, currentLivenessTask: livenessTask ) diff --git a/Sources/SmileID/Classes/Camera/CameraManager.swift b/Sources/SmileID/Classes/Camera/CameraManager.swift index 439f31d95..f57cee050 100644 --- a/Sources/SmileID/Classes/Camera/CameraManager.swift +++ b/Sources/SmileID/Classes/Camera/CameraManager.swift @@ -35,6 +35,8 @@ class CameraManager: NSObject, ObservableObject { (session.inputs.first as? AVCaptureDeviceInput)?.device.position } + private(set) var cameraName: String? + // Used to queue and then resume tasks while waiting for Camera permissions private let sessionQueue = DispatchQueue(label: "com.smileidentity.ios") private let videoOutput = AVCaptureVideoDataOutput() @@ -90,6 +92,7 @@ class CameraManager: NSObject, ObservableObject { status = .failed return } + cameraName = camera.uniqueID do { let cameraInput = try AVCaptureDeviceInput(device: camera) diff --git a/Sources/SmileID/Classes/Camera/CameraViewController.swift b/Sources/SmileID/Classes/Camera/CameraViewController.swift index 26f2797dd..d96b773d7 100644 --- a/Sources/SmileID/Classes/Camera/CameraViewController.swift +++ b/Sources/SmileID/Classes/Camera/CameraViewController.swift @@ -3,7 +3,7 @@ import Vision import AVFoundation class CameraViewController: UIViewController { - var faceDetector: FaceDetectorV2? + var faceDetector: EnhancedFaceDetector? var previewLayer: AVCaptureVideoPreviewLayer? private weak var cameraManager: CameraManager? diff --git a/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift b/Sources/SmileID/Classes/FaceDetector/EnhancedFaceDetector.swift similarity index 85% rename from Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift rename to Sources/SmileID/Classes/FaceDetector/EnhancedFaceDetector.swift index 8a9ba94f6..d00eee4a8 100644 --- a/Sources/SmileID/Classes/FaceDetector/FaceDetectorV2.swift +++ b/Sources/SmileID/Classes/FaceDetector/EnhancedFaceDetector.swift @@ -16,16 +16,15 @@ protocol FaceDetectorViewDelegate: NSObjectProtocol { protocol FaceDetectorResultDelegate: AnyObject { func faceDetector( - _ detector: FaceDetectorV2, + _ detector: EnhancedFaceDetector, didDetectFace faceGeometry: FaceGeometryData, withFaceQuality faceQuality: Float, - selfieQuality: SelfieQualityData, brightness: Int ) - func faceDetector(_ detector: FaceDetectorV2, didFailWithError error: Error) + func faceDetector(_ detector: EnhancedFaceDetector, didFailWithError error: Error) } -class FaceDetectorV2: NSObject { +class EnhancedFaceDetector: NSObject { private var selfieQualityModel: SelfieQualityDetector? private let cropSize = (width: 120, height: 120) @@ -78,29 +77,32 @@ class FaceDetectorV2: NSObject { let uiImage = UIImage(pixelBuffer: imageBuffer) let brightness = self.calculateBrightness(uiImage) - let croppedImage = try self.cropImageToFace(uiImage) - - let selfieQualityData = try self.selfieQualityRequest(imageBuffer: croppedImage) + let faceGeometryData: FaceGeometryData if #available(iOS 15.0, *) { - let faceGeometryData = FaceGeometryData( + faceGeometryData = FaceGeometryData( boundingBox: convertedBoundingBox, roll: faceObservation.roll ?? 0.0, yaw: faceObservation.yaw ?? 0.0, pitch: faceObservation.pitch ?? 0.0, direction: faceDirection(faceObservation: faceObservation) ) - self.resultDelegate? - .faceDetector( - self, - didDetectFace: faceGeometryData, - withFaceQuality: faceQualityObservation.faceCaptureQuality ?? 0.0, - selfieQuality: selfieQualityData, - brightness: brightness - ) - } else { - // Fallback on earlier versions + } else { // Fallback on earlier versions + faceGeometryData = FaceGeometryData( + boundingBox: convertedBoundingBox, + roll: faceObservation.roll ?? 0.0, + yaw: faceObservation.yaw ?? 0.0, + pitch: 0.0, + direction: faceDirection(faceObservation: faceObservation) + ) } + self.resultDelegate? + .faceDetector( + self, + didDetectFace: faceGeometryData, + withFaceQuality: faceQualityObservation.faceCaptureQuality ?? 0.0, + brightness: brightness + ) } catch { self.resultDelegate?.faceDetector(self, didFailWithError: error) } @@ -180,8 +182,8 @@ class FaceDetectorV2: NSObject { private func calculateBrightness(_ image: UIImage?) -> Int { guard let image, let cgImage = image.cgImage, - let imageData = cgImage.dataProvider?.data, - let dataPointer = CFDataGetBytePtr(imageData) + let imageData = cgImage.dataProvider?.data, + let dataPointer = CFDataGetBytePtr(imageData) else { return 0 } diff --git a/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift b/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift index bee6b2ece..80f54e312 100644 --- a/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift +++ b/Sources/SmileID/Classes/FaceDetector/FaceValidator.swift @@ -15,9 +15,10 @@ final class FaceValidator { private var faceLayoutGuideFrame: CGRect = .zero // MARK: Constants - private let selfieQualityThreshold: Float = 0.5 - private let luminanceThreshold: ClosedRange = 80...200 - private let faceBoundsMultiplier: CGFloat = 1.5 + private let faceQualityThreshold: Float = 0.25 + private let luminanceThreshold: ClosedRange = 40...200 + private let selfiefaceBoundsMultiplier: CGFloat = 1.5 + private let livenessfaceBoundsMultiplier: CGFloat = 2.2 private let faceBoundsThreshold: CGFloat = 50 init() {} @@ -28,7 +29,7 @@ final class FaceValidator { func validate( faceGeometry: FaceGeometryData, - selfieQuality: SelfieQualityData, + faceQuality: Float, brightness: Int, currentLivenessTask: LivenessTask? ) { @@ -42,14 +43,14 @@ final class FaceValidator { // check brightness let isAcceptableBrightness = luminanceThreshold.contains(brightness) - // check selfie quality - let isAcceptableSelfieQuality = checkSelfieQuality(selfieQuality) + // check face quality + let isAcceptableFaceQuality = checkFaceQuality(faceQuality) // check that face is ready for capture let hasDetectedValidFace = checkValidFace( isAcceptableBounds, isAcceptableBrightness, - isAcceptableSelfieQuality + isAcceptableFaceQuality ) // determine what instruction/animation to display to users @@ -57,7 +58,7 @@ final class FaceValidator { from: faceBoundsState, detectedValidFace: hasDetectedValidFace, isAcceptableBrightness: isAcceptableBrightness, - isAcceptableSelfieQuality: isAcceptableSelfieQuality, + isAcceptableFaceQuality: isAcceptableFaceQuality, livenessTask: currentLivenessTask ) @@ -73,7 +74,7 @@ final class FaceValidator { from faceBoundsState: FaceBoundsState, detectedValidFace: Bool, isAcceptableBrightness: Bool, - isAcceptableSelfieQuality: Bool, + isAcceptableFaceQuality: Bool, livenessTask: LivenessTask? ) -> SelfieCaptureInstruction? { if detectedValidFace { @@ -88,29 +89,36 @@ final class FaceValidator { } } return nil - } else if faceBoundsState == .detectedFaceOffCentre { + } else if faceBoundsState == .detectedFaceOffCentre + || faceBoundsState == .detectedFaceNotWithinFrame { return .headInFrame } else if faceBoundsState == .detectedFaceTooSmall { return .moveCloser } else if faceBoundsState == .detectedFaceTooLarge { return .moveBack - } else if !isAcceptableSelfieQuality || !isAcceptableBrightness { + } else if !isAcceptableFaceQuality || !isAcceptableBrightness { return .goodLight } return nil } // MARK: Validation Checks - private func checkFaceSizeAndPosition(using boundingBox: CGRect, shouldCheckCentering: Bool) -> FaceBoundsState { + private func checkFaceSizeAndPosition( + using boundingBox: CGRect, + shouldCheckCentering: Bool + ) -> FaceBoundsState { let maxFaceWidth = faceLayoutGuideFrame.width - 20 + let faceBoundsMultiplier = shouldCheckCentering ? selfiefaceBoundsMultiplier : livenessfaceBoundsMultiplier let minFaceWidth = faceLayoutGuideFrame.width / faceBoundsMultiplier + // check how far/close face is if boundingBox.width > maxFaceWidth { return .detectedFaceTooLarge } else if boundingBox.width < minFaceWidth { return .detectedFaceTooSmall } + // check that face is centered for selfie capture only if shouldCheckCentering { let horizontalOffset = abs(boundingBox.midX - faceLayoutGuideFrame.midX) let verticalOffset = abs(boundingBox.midY - faceLayoutGuideFrame.midY) @@ -123,15 +131,15 @@ final class FaceValidator { return .detectedFaceAppropriateSizeAndPosition } - private func checkSelfieQuality(_ value: SelfieQualityData) -> Bool { - return value.passed >= selfieQualityThreshold + private func checkFaceQuality(_ value: Float) -> Bool { + return value >= faceQualityThreshold } private func checkValidFace( _ isAcceptableBounds: Bool, _ isAcceptableBrightness: Bool, - _ isAcceptableSelfieQuality: Bool + _ isAcceptableFaceQuality: Bool ) -> Bool { - return isAcceptableBounds && isAcceptableBrightness && isAcceptableSelfieQuality + return isAcceptableBounds && isAcceptableBrightness && isAcceptableFaceQuality } } diff --git a/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift b/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift index 9805aa481..033ed0f35 100644 --- a/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift +++ b/Sources/SmileID/Classes/FaceDetector/LivenessCheckManager.swift @@ -6,6 +6,20 @@ enum LivenessTask { case lookLeft case lookRight case lookUp + + static var numberOfFramesToCapture: Int { + if #available(iOS 15.0, *) { + return 2 + } else { + return 3 + } + } +} + +protocol LivenessCheckManagerDelegate: AnyObject { + func didCompleteLivenessTask() + func didCompleteLivenessChallenge() + func livenessChallengeTimeout() } class LivenessCheckManager: ObservableObject { @@ -13,20 +27,18 @@ class LivenessCheckManager: ObservableObject { private var livenessTaskSequence: [LivenessTask] = [] /// The index pointing to the current task in the sequence. private var currentTaskIndex: Int = 0 - /// The view model associated with the selfie capture process. - weak var selfieViewModel: SelfieViewModelV2? - /// A closure to trigger photo capture during the liveness check. - var captureImage: (() -> Void)? + + weak var delegate: LivenessCheckManagerDelegate? // MARK: Constants /// The minimum threshold for yaw (left-right head movement) private let minYawAngleThreshold: CGFloat = 0.15 /// The maximum threshold for yaw (left-right head movement) - private let maxYawAngleThreshold: CGFloat = 0.3 + private let maxYawAngleThreshold: CGFloat = 0.25 /// The minimum threshold for pitch (up-down head movement) private let minPitchAngleThreshold: CGFloat = 0.15 /// The maximum threshold for pitch (up-down head movement) - private let maxPitchAngleThreshold: CGFloat = 0.3 + private let maxPitchAngleThreshold: CGFloat = 0.25 /// The timeout duration for each task in seconds. private let taskTimeoutDuration: TimeInterval = 120 @@ -51,7 +63,11 @@ class LivenessCheckManager: ObservableObject { /// Initializes the LivenessCheckManager with a shuffled set of tasks. init() { - livenessTaskSequence = [.lookLeft, .lookRight, .lookUp].shuffled() + if #available(iOS 15.0, *) { + livenessTaskSequence = [.lookLeft, .lookRight, .lookUp].shuffled() + } else { + livenessTaskSequence = [.lookLeft, .lookRight].shuffled() + } } /// Cleans up resources when the manager is no longer needed. @@ -65,7 +81,8 @@ class LivenessCheckManager: ObservableObject { DispatchQueue.main.async { self.taskTimer = Timer.scheduledTimer( withTimeInterval: 1.0, - repeats: true) { [weak self] _ in + repeats: true + ) { [weak self] _ in self?.taskTimerFired() } } @@ -88,7 +105,7 @@ class LivenessCheckManager: ObservableObject { /// Handles the timeout event for a task. private func handleTaskTimeout() { stopTaskTimer() - selfieViewModel?.perform(action: .activeLivenessTimeout) + delegate?.livenessChallengeTimeout() } /// Advances to the next task in the sequence @@ -160,12 +177,11 @@ class LivenessCheckManager: ObservableObject { /// Completes the current task and moves to the next one. /// If all tasks are completed, it signals the completion of the liveness challenge. private func completeCurrentTask() { - captureImage?() - captureImage?() + delegate?.didCompleteLivenessTask() if !advanceToNextTask() { // Liveness challenge complete - selfieViewModel?.perform(action: .activeLivenessCompleted) + delegate?.didCompleteLivenessChallenge() self.currentTask = nil } } diff --git a/Sources/SmileID/Classes/FaceDetector/Models.swift b/Sources/SmileID/Classes/FaceDetector/Models.swift index 0d0bdd2ac..6566942c1 100644 --- a/Sources/SmileID/Classes/FaceDetector/Models.swift +++ b/Sources/SmileID/Classes/FaceDetector/Models.swift @@ -17,6 +17,7 @@ enum FaceBoundsState { case detectedFaceTooSmall case detectedFaceTooLarge case detectedFaceOffCentre + case detectedFaceNotWithinFrame case detectedFaceAppropriateSizeAndPosition } diff --git a/Sources/SmileID/Classes/Helpers/Backport.swift b/Sources/SmileID/Classes/Helpers/Backport.swift new file mode 100644 index 000000000..118f4b594 --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/Backport.swift @@ -0,0 +1,52 @@ +// swiftlint:disable all +import SwiftUI +import ObjectiveC + +/// Provides a convenient method for backporting API, +/// including types, functions, properties, property wrappers and more. +/// +/// To backport a SwiftUI Label for example, you could apply the +/// following extension: +/// +/// extension Backport where Content == Any { +/// public struct Label { } +/// } +/// +/// Now if we want to provide further extensions to our backport type, +/// we need to ensure we retain the `Content == Any` generic requirement: +/// +/// extension Backport.Label where Content == Any, Title == Text, Icon == Image { +/// public init(_ title: S, systemName: String) { } +/// } +/// +/// In addition to types, we can also provide backports for properties +/// and methods: +/// +/// extension Backport.Label where Content: View { +/// func onChange(of value: Value, perform action: (Value) -> Void) -> some View { +/// // `content` provides access to the extended type +/// content.modifier(OnChangeModifier(value, action)) +/// } +/// } +/// +public struct Backport { + /// The underlying content this backport represents. + public let wrapped: Wrapped + + /// Initializes a new Backport for the specified content. + /// - Parameter content: The content (type) that's being backported + public init(_ wrapped: Wrapped) { + self.wrapped = wrapped + } +} + +public extension Backport where Wrapped == Any { + init(_ wrapped: Wrapped) { + self.wrapped = wrapped + } +} + +public extension NSObjectProtocol { + /// Wraps an `NSObject` that can be extended to provide backport functionality. + var backport: Backport { .init(self) } +} diff --git a/Sources/SmileID/Classes/Helpers/DMSansFont.swift b/Sources/SmileID/Classes/Helpers/DMSansFont.swift new file mode 100644 index 000000000..e4ea80da5 --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/DMSansFont.swift @@ -0,0 +1,36 @@ +import SwiftUI + +enum DMSans: String, CaseIterable { + case regular = "DMSans-Regular" + case medium = "DMSans-Medium" + case bold = "DMSans-Bold" +} + +public struct DMSansFont: FontType { + public static var medium: Font { + medium(with: SmileIDResourcesHelper.pointSize) + } + + public static var bold: Font { + bold(with: SmileIDResourcesHelper.pointSize) + } + + public static var pointSize: CGFloat { + SmileIDResourcesHelper.pointSize + } + + public static func regular(with size: CGFloat) -> Font { + SmileIDResourcesHelper.loadFontIfNeeded(name: DMSans.regular.rawValue) + return Font.custom(DMSans.regular.rawValue, size: size) + } + + public static func medium(with size: CGFloat) -> Font { + SmileIDResourcesHelper.loadFontIfNeeded(name: DMSans.medium.rawValue) + return Font.custom(DMSans.medium.rawValue, size: size) + } + + public static func bold(with size: CGFloat) -> Font { + SmileIDResourcesHelper.loadFontIfNeeded(name: DMSans.bold.rawValue) + return Font.custom(DMSans.bold.rawValue, size: size) + } +} diff --git a/Sources/SmileID/Classes/Helpers/DeviceRotationViewModifier.swift b/Sources/SmileID/Classes/Helpers/DeviceRotationViewModifier.swift new file mode 100644 index 000000000..4ca0cc07c --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/DeviceRotationViewModifier.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct DeviceRotationViewModifier: ViewModifier { + let action: (UIDeviceOrientation) -> Void + + func body(content: Content) -> some View { + content + .onAppear() + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + action(UIDevice.current.orientation) + } + } +} + +extension View { + func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { + self.modifier(DeviceRotationViewModifier(action: action)) + } +} diff --git a/Sources/SmileID/Classes/Helpers/HapticManager.swift b/Sources/SmileID/Classes/Helpers/HapticManager.swift new file mode 100644 index 000000000..fd1cf28b3 --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/HapticManager.swift @@ -0,0 +1,23 @@ +import UIKit + +class HapticManager { + static let shared = HapticManager() + + private init() {} + + // MARK: Notification Feedback + + /// Triggers a notification haptic feedback + /// - Parameter type: The notification type (success, warning, error) + func notification(type: UINotificationFeedbackGenerator.FeedbackType) { + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(type) + } + + // MARK: Impact Feedback + + func impact(style: UIImpactFeedbackGenerator.FeedbackStyle) { + let generator = UIImpactFeedbackGenerator(style: style) + generator.impactOccurred() + } +} diff --git a/Sources/SmileID/Classes/Helpers/StateObject.swift b/Sources/SmileID/Classes/Helpers/StateObject.swift new file mode 100644 index 000000000..098f2392f --- /dev/null +++ b/Sources/SmileID/Classes/Helpers/StateObject.swift @@ -0,0 +1,151 @@ +// swiftlint:disable all +import Combine +import SwiftUI + +@available(iOS, deprecated: 14.0) +@available(macOS, deprecated: 11.0) +@available(tvOS, deprecated: 14.0) +@available(watchOS, deprecated: 7.0) +public extension Backport where Wrapped: ObservableObject { + + /// A property wrapper type that instantiates an observable object. + /// + /// Create a state object in a ``SwiftUI/View``, ``SwiftUI/App``, or + /// ``SwiftUI/Scene`` by applying the `@Backport.StateObject` attribute to a property + /// declaration and providing an initial value that conforms to the + /// + /// protocol: + /// + /// @Backport.StateObject var model = DataModel() + /// + /// SwiftUI creates a new instance of the object only once for each instance of + /// the structure that declares the object. When published properties of the + /// observable object change, SwiftUI updates the parts of any view that depend + /// on those properties: + /// + /// Text(model.title) // Updates the view any time `title` changes. + /// + /// You can pass the state object into a property that has the + /// ``SwiftUI/ObservedObject`` attribute. You can alternatively add the object + /// to the environment of a view hierarchy by applying the + /// ``SwiftUI/View/environmentObject(_:)`` modifier: + /// + /// ContentView() + /// .environmentObject(model) + /// + /// If you create an environment object as shown in the code above, you can + /// read the object inside `ContentView` or any of its descendants + /// using the ``SwiftUI/EnvironmentObject`` attribute: + /// + /// @EnvironmentObject var model: DataModel + /// + /// Get a ``SwiftUI/Binding`` to one of the state object's properties using the + /// `$` operator. Use a binding when you want to create a two-way connection to + /// one of the object's properties. For example, you can let a + /// ``SwiftUI/Toggle`` control a Boolean value called `isEnabled` stored in the + /// model: + /// + /// Toggle("Enabled", isOn: $model.isEnabled) + @propertyWrapper struct StateObject: DynamicProperty { + private final class Wrapper: ObservableObject { + private var subject = PassthroughSubject() + + var value: Wrapped? { + didSet { + cancellable = nil + cancellable = value?.objectWillChange + .sink { [subject] _ in subject.send() } + } + } + + private var cancellable: AnyCancellable? + + var objectWillChange: AnyPublisher { + subject.eraseToAnyPublisher() + } + } + + @State private var state = Wrapper() + + @ObservedObject private var observedObject = Wrapper() + + private var thunk: () -> Wrapped + + /// The underlying value referenced by the state object. + /// + /// The wrapped value property provides primary access to the value's data. + /// However, you don't access `wrappedValue` directly. Instead, use the + /// property variable created with the `@Backport.StateObject` attribute: + /// + /// @Backport.StateObject var contact = Contact() + /// + /// var body: some View { + /// Text(contact.name) // Accesses contact's wrapped value. + /// } + /// + /// When you change a property of the wrapped value, you can access the new + /// value immediately. However, SwiftUI updates views displaying the value + /// asynchronously, so the user interface might not update immediately. + public var wrappedValue: Wrapped { + if let object = state.value { + return object + } else { + let object = thunk() + state.value = object + return object + } + } + + /// A projection of the state object that creates bindings to its + /// properties. + /// + /// Use the projected value to pass a binding value down a view hierarchy. + /// To get the projected value, prefix the property variable with `$`. For + /// example, you can get a binding to a model's `isEnabled` Boolean so that + /// a ``SwiftUI/Toggle`` view can control the value: + /// + /// struct MyView: View { + /// @Backport.StateObject var model = DataModel() + /// + /// var body: some View { + /// Toggle("Enabled", isOn: $model.isEnabled) + /// } + /// } + public var projectedValue: ObservedObject.Wrapper { + ObservedObject(wrappedValue: wrappedValue).projectedValue + } + + /// Creates a new state object with an initial wrapped value. + /// + /// You don’t call this initializer directly. Instead, declare a property + /// with the `@Backport.StateObject` attribute in a ``SwiftUI/View``, + /// ``SwiftUI/App``, or ``SwiftUI/Scene``, and provide an initial value: + /// + /// struct MyView: View { + /// @Backport.StateObject var model = DataModel() + /// + /// // ... + /// } + /// + /// SwiftUI creates only one instance of the state object for each + /// container instance that you declare. In the code above, SwiftUI + /// creates `model` only the first time it initializes a particular instance + /// of `MyView`. On the other hand, each different instance of `MyView` + /// receives a distinct copy of the data model. + /// + /// - Parameter thunk: An initial value for the state object. + public init(wrappedValue thunk: @autoclosure @escaping () -> Wrapped) { + self.thunk = thunk + } + + public mutating func update() { + if state.value == nil { + state.value = thunk() + } + if observedObject.value !== state.value { + observedObject.value = state.value + } + } + } + +} diff --git a/Sources/SmileID/Classes/Helpers/Theme.swift b/Sources/SmileID/Classes/Helpers/Theme.swift index e3a73b020..40ba54432 100644 --- a/Sources/SmileID/Classes/Helpers/Theme.swift +++ b/Sources/SmileID/Classes/Helpers/Theme.swift @@ -68,19 +68,23 @@ public extension SmileIdTheme { // TO-DO: Rename fonts when Kwame comes up with a naming convention var header1: Font { - EpilogueFont.bold(with: 32) + DMSansFont.bold(with: 24) } var header2: Font { - EpilogueFont.bold(with: 20) + DMSansFont.bold(with: 20) + } + + var header3: Font { + DMSansFont.medium(with: 20) } var header4: Font { - EpilogueFont.bold(with: 16) + DMSansFont.medium(with: 16) } var header5: Font { - EpilogueFont.medium(with: 12) + DMSansFont.medium(with: 12) } var button: Font { @@ -88,7 +92,7 @@ public extension SmileIdTheme { } var body: Font { - EpilogueFont.medium(with: 14) + DMSansFont.regular(with: 16) } } diff --git a/Sources/SmileID/Classes/Networking/Models/FailureReason.swift b/Sources/SmileID/Classes/Networking/Models/FailureReason.swift index 8d901a64c..f68bcd028 100644 --- a/Sources/SmileID/Classes/Networking/Models/FailureReason.swift +++ b/Sources/SmileID/Classes/Networking/Models/FailureReason.swift @@ -1,11 +1,17 @@ import Foundation -public enum FailureReason { - case activeLivenessTimedOut +public enum FailureReason: Encodable { + case mobileActiveLivenessTimeout - var key: String { + private enum CodingKeys: String, CodingKey { + case mobileActiveLivenessTimeout = "mobile_active_liveness_timed_out" + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .activeLivenessTimedOut: return "mobile_active_liveness_timed_out" + case .mobileActiveLivenessTimeout: + try container.encode(true, forKey: .mobileActiveLivenessTimeout) } } } diff --git a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift index 16a944630..567e92f7a 100644 --- a/Sources/SmileID/Classes/Networking/ServiceRunnable.swift +++ b/Sources/SmileID/Classes/Networking/ServiceRunnable.swift @@ -337,15 +337,13 @@ extension ServiceRunnable { body.append(lineBreak.data(using: .utf8)!) // Append failure reason if available - if let failureReason { - let activeLivenessTimedOutString = "\(failureReason == .activeLivenessTimedOut)" - if let valueData = "\(activeLivenessTimedOutString)\(lineBreak)".data(using: .utf8) { - body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) - body.append( - "Content-Disposition: form-data; name=\"\(failureReason.key)\"\(lineBreak + lineBreak)".data( - using: .utf8)!) - body.append(valueData) - } + if let failureReason, + let failureReasonData = try? encoder.encode(failureReason) { + body.append("--\(boundary)\(lineBreak)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"failure_reason\"\(lineBreak)".data(using: .utf8)!) + body.append("Content-Type: application/json\(lineBreak + lineBreak)".data(using: .utf8)!) + body.append(failureReasonData) + body.append(lineBreak.data(using: .utf8)!) } // Append final boundary diff --git a/Sources/SmileID/Classes/SelfieCapture/CaptureGuideAnimation.swift b/Sources/SmileID/Classes/SelfieCapture/CaptureGuideAnimation.swift index 13d274ba0..b9c3f26a8 100644 --- a/Sources/SmileID/Classes/SelfieCapture/CaptureGuideAnimation.swift +++ b/Sources/SmileID/Classes/SelfieCapture/CaptureGuideAnimation.swift @@ -8,11 +8,12 @@ enum CaptureGuideAnimation: Equatable { case lookRight case lookLeft case lookUp + case turnPhoneUp var fileName: String { switch self { case .goodLight: - return "light_animation" + return "light_animation_with_bg" case .headInFrame: return "positioning" case .moveBack: @@ -20,11 +21,13 @@ enum CaptureGuideAnimation: Equatable { case .moveCloser: return "positioning" case .lookRight: - return "liveness_guides" + return "liveness_guides_with_bg" case .lookLeft: - return "liveness_guides" + return "liveness_guides_with_bg" case .lookUp: - return "liveness_guides" + return "liveness_guides_with_bg" + case .turnPhoneUp: + return "device_orientation" } } diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift b/Sources/SmileID/Classes/SelfieCapture/EnhancedSmartSelfieViewModel.swift similarity index 68% rename from Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift rename to Sources/SmileID/Classes/SelfieCapture/EnhancedSmartSelfieViewModel.swift index 8057d48f7..b0cf63d08 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelV2.swift +++ b/Sources/SmileID/Classes/SelfieCapture/EnhancedSmartSelfieViewModel.swift @@ -2,10 +2,10 @@ import ARKit import Combine import SwiftUI -public class SelfieViewModelV2: ObservableObject { +public class EnhancedSmartSelfieViewModel: ObservableObject { // MARK: Dependencies let cameraManager = CameraManager.shared - let faceDetector = FaceDetectorV2() + let faceDetector = EnhancedFaceDetector() private let faceValidator = FaceValidator() var livenessCheckManager = LivenessCheckManager() private var subscribers = Set() @@ -13,25 +13,31 @@ public class SelfieViewModelV2: ObservableObject { private let metadataTimerStart = MonotonicTime() // MARK: Private Properties + private var deviceOrientation: UIDeviceOrientation { + return UIDevice.current.orientation + } private var faceLayoutGuideFrame = CGRect(x: 0, y: 0, width: 250, height: 350) private var elapsedGuideAnimationDelay: TimeInterval = 0 + private var currentFrameBuffer: CVPixelBuffer? var selfieImage: UIImage? - var selfieImageURL: URL? { + private var selfieImageURL: URL? { didSet { DispatchQueue.main.async { self.selfieCaptured = self.selfieImage != nil } } } - var livenessImages: [URL] = [] + private var livenessImages: [URL] = [] private var hasDetectedValidFace: Bool = false + private var isCapturingLivenessImages = false private var shouldBeginLivenessChallenge: Bool { hasDetectedValidFace && selfieImage != nil && livenessCheckManager.currentTask != nil } private var shouldSubmitJob: Bool { selfieImage != nil && livenessImages.count == numLivenessImages } - private var forcedFailure: Bool = false + private var submissionTask: Task? + private var failureReason: FailureReason? private var apiResponse: SmartSelfieResponse? private var error: Error? @Published public var errorMessageRes: String? @@ -100,13 +106,15 @@ public class SelfieViewModelV2: ObservableObject { } deinit { + subscribers.removeAll() stopGuideAnimationDelayTimer() + invalidateSubmissionTask() } private func initialSetup() { self.faceValidator.delegate = self self.faceDetector.resultDelegate = self - self.livenessCheckManager.selfieViewModel = self + self.livenessCheckManager.delegate = self self.faceValidator.setLayoutGuideFrame(with: faceLayoutGuideFrame) self.userInstruction = .headInFrame @@ -116,6 +124,7 @@ public class SelfieViewModelV2: ObservableObject { with: livenessCheckManager.$lookRightProgress, livenessCheckManager.$lookUpProgress ) + .receive(on: DispatchQueue.main) .sink { [weak self] _ in DispatchQueue.main.async { self?.resetGuideAnimationDelayTimer() @@ -127,46 +136,48 @@ public class SelfieViewModelV2: ObservableObject { .receive(on: DispatchQueue.main) .filter { $0 == .unauthorized } .map { _ in AlertState.cameraUnauthorized } - .sink { alert in self.unauthorizedAlert = alert } + .sink { [weak self] alert in self?.unauthorizedAlert = alert } .store(in: &subscribers) cameraManager.sampleBufferPublisher + .receive(on: DispatchQueue.main) .throttle( for: 0.35, scheduler: DispatchQueue.global(qos: .userInitiated), latest: true ) // Drop the first ~2 seconds to allow the user to settle in - .dropFirst(5) + .dropFirst(5) .compactMap { $0 } - .sink(receiveValue: analyzeFrame) + .sink { [weak self] imageBuffer in + self?.handleCameraImageBuffer(imageBuffer) + } .store(in: &subscribers) } + private func handleCameraImageBuffer(_ imageBuffer: CVPixelBuffer) { + if deviceOrientation == .portrait { + analyzeFrame(imageBuffer: imageBuffer) + } else { + publishUserInstruction(.turnPhoneUp) + } + } + private func analyzeFrame(imageBuffer: CVPixelBuffer) { + currentFrameBuffer = imageBuffer faceDetector.processImageBuffer(imageBuffer) if hasDetectedValidFace && selfieImage == nil { captureSelfieImage(imageBuffer) + HapticManager.shared.notification(type: .success) livenessCheckManager.initiateLivenessCheck() } - - livenessCheckManager.captureImage = { [weak self] in - self?.captureLivenessImage(imageBuffer) - } } // MARK: Actions func perform(action: SelfieViewModelAction) { switch action { case let .windowSizeDetected(windowRect, safeAreaInsets): - handleWindowSizeChanged(toRect: windowRect, edgeInsets: safeAreaInsets) - case .activeLivenessCompleted: - self.cameraManager.pauseSession() - handleSubmission() - case .activeLivenessTimeout: - self.forcedFailure = true - self.cameraManager.pauseSession() - handleSubmission() + handleWindowSizeChanged(to: windowRect, edgeInsets: safeAreaInsets) case .onViewAppear: handleViewAppeared() case .jobProcessingDone: @@ -189,7 +200,7 @@ public class SelfieViewModelV2: ObservableObject { } // MARK: Action Handlers -extension SelfieViewModelV2 { +extension EnhancedSmartSelfieViewModel { private func resetGuideAnimationDelayTimer() { elapsedGuideAnimationDelay = 0 showGuideAnimation = false @@ -219,14 +230,13 @@ extension SelfieViewModelV2 { selfieImage = nil livenessImages = [] selfieCaptureState = .capturingSelfie - forcedFailure = false + failureReason = nil } - private func handleWindowSizeChanged(toRect: CGSize, edgeInsets: EdgeInsets) { + private func handleWindowSizeChanged(to rect: CGSize, edgeInsets: EdgeInsets) { let topPadding: CGFloat = edgeInsets.top + 100 - print(edgeInsets.top) faceLayoutGuideFrame = CGRect( - x: (toRect.width / 2) - faceLayoutGuideFrame.width / 2, + x: (rect.width / 2) - faceLayoutGuideFrame.width / 2, y: topPadding, width: faceLayoutGuideFrame.width, height: faceLayoutGuideFrame.height @@ -241,17 +251,49 @@ extension SelfieViewModelV2 { pixelBuffer, height: selfieImageSize, orientation: .up - ) + ), + let uiImage = UIImage(data: imageData) else { throw SmileIDError.unknown("Error resizing selfie image") } - self.selfieImage = UIImage(data: imageData) + self.selfieImage = flipImageForPreview(uiImage) self.selfieImageURL = try LocalStorage.createSelfieFile(jobId: jobId, selfieFile: imageData) } catch { handleError(error) } } + private func flipImageForPreview(_ image: UIImage) -> UIImage? { + guard let cgImage = image.cgImage else { return nil } + + let contextSize = CGSize(width: image.size.width, height: image.size.height) + UIGraphicsBeginImageContextWithOptions(contextSize, false, 1.0) + defer { + UIGraphicsEndImageContext() + } + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + // Apply a 180° counterclockwise rotation + // Translate the context to the center before rotating + // to ensure the image rotates around its center + context.translateBy(x: contextSize.width / 2, y: contextSize.height / 2) + context.rotate(by: -.pi) + + // Draw the image + context.draw( + cgImage, + in: CGRect( + x: -image.size.width / 2, y: -image.size.height / 2, width: image.size.width, height: image.size.height) + ) + + // Get the new UIImage from the context + let correctedImage = UIGraphicsGetImageFromCurrentImageContext() + + return correctedImage + } + private func captureLivenessImage(_ pixelBuffer: CVPixelBuffer) { do { guard @@ -271,14 +313,15 @@ extension SelfieViewModelV2 { } private func handleError(_ error: Error) { - print(error.localizedDescription) + debugPrint(error.localizedDescription) } private func handleSubmission() { DispatchQueue.main.async { self.selfieCaptureState = .processing(.inProgress) } - Task { + guard submissionTask == nil else { return } + submissionTask = Task { try await submitJob() } } @@ -290,36 +333,34 @@ extension SelfieViewModelV2 { } // MARK: FaceDetectorResultDelegate Methods -extension SelfieViewModelV2: FaceDetectorResultDelegate { +extension EnhancedSmartSelfieViewModel: FaceDetectorResultDelegate { func faceDetector( - _ detector: FaceDetectorV2, + _ detector: EnhancedFaceDetector, didDetectFace faceGeometry: FaceGeometryData, withFaceQuality faceQuality: Float, - selfieQuality: SelfieQualityData, brightness: Int ) { faceValidator .validate( faceGeometry: faceGeometry, - selfieQuality: selfieQuality, + faceQuality: faceQuality, brightness: brightness, currentLivenessTask: self.livenessCheckManager.currentTask ) - if shouldBeginLivenessChallenge { + if shouldBeginLivenessChallenge && !isCapturingLivenessImages { livenessCheckManager.processFaceGeometry(faceGeometry) } } - func faceDetector(_ detector: FaceDetectorV2, didFailWithError error: Error) { + func faceDetector(_ detector: EnhancedFaceDetector, didFailWithError error: Error) { DispatchQueue.main.async { self.publishUserInstruction(.headInFrame) } - print(error.localizedDescription) } } // MARK: FaceValidatorDelegate Methods -extension SelfieViewModelV2: FaceValidatorDelegate { +extension EnhancedSmartSelfieViewModel: FaceValidatorDelegate { func updateValidationResult(_ result: FaceValidationResult) { DispatchQueue.main.async { self.faceInBounds = result.faceInBounds @@ -329,8 +370,57 @@ extension SelfieViewModelV2: FaceValidatorDelegate { } } +// MARK: LivenessCheckManagerDelegate Methods +extension EnhancedSmartSelfieViewModel: LivenessCheckManagerDelegate { + func didCompleteLivenessTask() { + isCapturingLivenessImages = true + let capturedFrames = 0 + captureNextFrame(capturedFrames: capturedFrames) + } + + private func captureNextFrame(capturedFrames: Int) { + let maxFrames = LivenessTask.numberOfFramesToCapture + guard capturedFrames < maxFrames, + let currentFrame = currentFrameBuffer else { + return + } + + captureLivenessImage(currentFrame) + let nextCapturedFrames = capturedFrames + 1 + if nextCapturedFrames < maxFrames { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.captureNextFrame(capturedFrames: nextCapturedFrames) + } + } else { + isCapturingLivenessImages = false + HapticManager.shared.notification(type: .success) + } + } + + func didCompleteLivenessChallenge() { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.cameraManager.pauseSession() + self.handleSubmission() + } + } + + func livenessChallengeTimeout() { + let remainingImages = numLivenessImages - livenessImages.count + let count = remainingImages > 0 ? remainingImages : 0 + for _ in 0.. (MultipartBody, [MultipartBody]) { - guard let smartSelfieImage = createMultipartBody(from: selfieImage) else { - throw SmileIDError.unknown("Failed to process selfie image") + guard let smartSelfieImage = createMultipartBody(from: selfieImageUrl) else { + throw SmileIDError.fileNotFound("Could not create multipart body for file") } let smartSelfieLivenessImages = livenessImages.compactMap { @@ -136,7 +131,9 @@ final class SelfieSubmissionManager { private func createMultipartBody(from fileURL: URL?) -> MultipartBody? { guard let fileURL = fileURL, let imageData = try? Data(contentsOf: fileURL) - else { return nil } + else { + return nil + } return MultipartBody( withImage: imageData, forKey: fileURL.lastPathComponent, @@ -148,7 +145,7 @@ final class SelfieSubmissionManager { authResponse: AuthenticationResponse, smartSelfieImage: MultipartBody, smartSelfieLivenessImages: [MultipartBody], - forcedFailure: Bool + failureReason: FailureReason? ) async throws -> SmartSelfieResponse { if isEnroll { return try await SmileID.api @@ -162,7 +159,7 @@ final class SelfieSubmissionManager { callbackUrl: SmileID.callbackUrl, sandboxResult: nil, allowNewEnroll: allowNewEnroll, - failureReason: forcedFailure ? .activeLivenessTimedOut : nil, + failureReason: failureReason, metadata: localMetadata.metadata ) } else { @@ -176,7 +173,7 @@ final class SelfieSubmissionManager { partnerParams: extraPartnerParams, callbackUrl: SmileID.callbackUrl, sandboxResult: nil, - failureReason: forcedFailure ? .activeLivenessTimedOut : nil, + failureReason: failureReason, metadata: localMetadata.metadata ) } @@ -187,7 +184,7 @@ final class SelfieSubmissionManager { try LocalStorage.moveToSubmittedJobs(jobId: self.jobId) // Update the references to the submitted selfie and liveness images - self.selfieImage = try LocalStorage.getFileByType( + self.selfieImageUrl = try LocalStorage.getFileByType( jobId: jobId, fileType: FileType.selfie, submitted: true @@ -204,7 +201,7 @@ final class SelfieSubmissionManager { do { let didMove = try LocalStorage.handleOfflineJobFailure(jobId: self.jobId, error: smileIDError) if didMove { - self.selfieImage = try LocalStorage.getFileByType(jobId: jobId, fileType: .selfie, submitted: true) + self.selfieImageUrl = try LocalStorage.getFileByType(jobId: jobId, fileType: .selfie, submitted: true) self.livenessImages = try LocalStorage.getFilesByType(jobId: jobId, fileType: .liveness, submitted: true) ?? [] } diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift index c41e1270f..a3097ca03 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModel.swift @@ -82,7 +82,7 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { .receive(on: DispatchQueue.main) .filter { $0 == .unauthorized } .map { _ in AlertState.cameraUnauthorized } - .sink { alert in self.unauthorizedAlert = alert } + .sink { [weak self] alert in self?.unauthorizedAlert = alert } .store(in: &subscribers) cameraManager.sampleBufferPublisher @@ -91,7 +91,9 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { // Drop the first ~2 seconds to allow the user to settle in .dropFirst(5) .compactMap { $0 } - .sink(receiveValue: analyzeImage) + .sink { [weak self] imageBuffer in + self?.analyzeImage(image: imageBuffer) + } .store(in: &subscribers) localMetadata.addMetadata( @@ -116,7 +118,8 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { } do { - try faceDetector.detect(imageBuffer: image) { [self] request, error in + try faceDetector.detect(imageBuffer: image) { [weak self] request, error in + guard let self else { return } if let error { print("Error analyzing image: \(error.localizedDescription)") self.error = error @@ -190,8 +193,8 @@ public class SelfieViewModel: ObservableObject, ARKitSmileDelegate { let userNeedsToSmile = livenessImages.count > numLivenessImages / 2 - DispatchQueue.main.async { [self] in - directive = userNeedsToSmile ? "Instructions.Smile" : "Instructions.Capturing" + DispatchQueue.main.async { + self.directive = userNeedsToSmile ? "Instructions.Smile" : "Instructions.Capturing" } // TODO: Use mouth deformation as an alternate signal for non-ARKit capture diff --git a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift index 579803a9f..7b9c1c159 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SelfieViewModelAction.swift @@ -5,10 +5,7 @@ enum SelfieViewModelAction { case onViewAppear case windowSizeDetected(CGSize, EdgeInsets) - // Face Detection Actions - case activeLivenessCompleted - case activeLivenessTimeout - + // Job Submission Actions case jobProcessingDone case retryJobSubmission diff --git a/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift b/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift index afaeadbea..3e0680daa 100644 --- a/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift +++ b/Sources/SmileID/Classes/SelfieCapture/SmartSelfieResultDelegate.swift @@ -19,3 +19,8 @@ public protocol SmartSelfieResultDelegate { /// - Parameter error: The error returned from a failed selfie capture func didError(error: Error) } + +extension SmartSelfieResultDelegate { + /// The selfie capture operation was canceled. + func didCancel() {} +} diff --git a/Sources/SmileID/Classes/SelfieCapture/View/CameraView.swift b/Sources/SmileID/Classes/SelfieCapture/View/CameraView.swift index aa04685b2..98da6d547 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/CameraView.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/CameraView.swift @@ -8,7 +8,7 @@ struct CameraView: UIViewControllerRepresentable { init( cameraManager: CameraManager, - selfieViewModel: SelfieViewModelV2? = nil + selfieViewModel: EnhancedSmartSelfieViewModel? = nil ) { let controller = CameraViewController(cameraManager: cameraManager) controller.faceDetector = selfieViewModel?.faceDetector diff --git a/Sources/SmileID/Classes/SelfieCapture/View/SelfieCaptureScreenV2.swift b/Sources/SmileID/Classes/SelfieCapture/View/EnhancedSelfieCaptureScreen.swift similarity index 90% rename from Sources/SmileID/Classes/SelfieCapture/View/SelfieCaptureScreenV2.swift rename to Sources/SmileID/Classes/SelfieCapture/View/EnhancedSelfieCaptureScreen.swift index 3822a43c1..734fe0968 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/SelfieCaptureScreenV2.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/EnhancedSelfieCaptureScreen.swift @@ -1,12 +1,14 @@ import SwiftUI -public struct SelfieCaptureScreenV2: View { - @ObservedObject var viewModel: SelfieViewModelV2 +public struct EnhancedSelfieCaptureScreen: View { + @Backport.StateObject var viewModel: EnhancedSmartSelfieViewModel let showAttribution: Bool private let faceShape = FaceShape() @Environment(\.modalMode) private var modalMode + private(set) var originalBrightness = UIScreen.main.brightness + public var body: some View { GeometryReader { proxy in VStack(spacing: 10) { @@ -34,7 +36,8 @@ public struct SelfieCaptureScreenV2: View { showGuideAnimation: viewModel.showGuideAnimation, guideAnimation: viewModel.userInstruction?.guideAnimation ) - if let currentLivenessTask = viewModel.livenessCheckManager.currentTask { + if let currentLivenessTask = viewModel.livenessCheckManager.currentTask, + viewModel.faceInBounds { LivenessGuidesView( currentLivenessTask: currentLivenessTask, topArcProgress: $viewModel.livenessCheckManager.lookUpProgress, @@ -72,10 +75,10 @@ public struct SelfieCaptureScreenV2: View { Spacer() UserInstructionsView( instruction: processingState.title, - message: getErrorSubtitle( + message: processingState == .error ? getErrorSubtitle( errorMessageRes: viewModel.errorMessageRes, errorMessage: viewModel.errorMessage - ) + ) : nil ) } SubmissionStatusView(processState: processingState) @@ -110,10 +113,14 @@ public struct SelfieCaptureScreenV2: View { } .navigationBarHidden(true) .onAppear { + UIScreen.main.brightness = 1 + UIApplication.shared.isIdleTimerDisabled = true viewModel.perform(action: .windowSizeDetected(proxy.size, proxy.safeAreaInsets)) viewModel.perform(action: .onViewAppear) } .onDisappear { + UIScreen.main.brightness = originalBrightness + UIApplication.shared.isIdleTimerDisabled = false viewModel.cameraManager.pauseSession() } .alert(item: $viewModel.unauthorizedAlert) { alert in diff --git a/Sources/SmileID/Classes/SelfieCapture/View/FaceBoundingArea.swift b/Sources/SmileID/Classes/SelfieCapture/View/FaceBoundingArea.swift index 440e255fd..cac02325b 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/FaceBoundingArea.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/FaceBoundingArea.swift @@ -22,24 +22,18 @@ struct FaceBoundingArea: View { if let guideAnimation = guideAnimation, showGuideAnimation { - faceShape - .fill(.black.opacity(0.5)) - .frame(width: 270, height: 370) - .overlay( - LottieView { - try await DotLottieFile - .named( - guideAnimation.fileName, - bundle: SmileIDResourcesHelper.bundle - ) - } - .playbackMode(playbackMode) - .frame(width: 224, height: 224) - ) - .clipShape(faceShape) - .onAppear { - playbackMode = getPlaybackMode(guideAnimation) - } + LottieView { + try await DotLottieFile + .named( + guideAnimation.fileName, + bundle: SmileIDResourcesHelper.bundle + ) + } + .playbackMode(playbackMode) + .frame(width: 224, height: 224) + .onAppear { + playbackMode = getPlaybackMode(guideAnimation) + } } } } diff --git a/Sources/SmileID/Classes/SelfieCapture/View/FaceShapedProgressIndicator.swift b/Sources/SmileID/Classes/SelfieCapture/View/FaceShapedProgressIndicator.swift index f139dda96..6c2aa9e96 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/FaceShapedProgressIndicator.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/FaceShapedProgressIndicator.swift @@ -4,7 +4,7 @@ import SwiftUI struct FaceShapedProgressIndicator: View { let progress: Double private let strokeWidth = 10 - private let faceShape = FaceShape().scale(x: 0.8, y: 0.6).offset(y: -50) + private let faceShape = FaceShape().scale(x: 0.8, y: 0.55).offset(y: -50) private let bgColor = Color.white.opacity(0.8) var body: some View { bgColor diff --git a/Sources/SmileID/Classes/SelfieCapture/View/LivenessCaptureInstructionsView.swift b/Sources/SmileID/Classes/SelfieCapture/View/LivenessCaptureInstructionsView.swift index 153c4a50b..81dccba45 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/LivenessCaptureInstructionsView.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/LivenessCaptureInstructionsView.swift @@ -2,32 +2,25 @@ import Lottie import SwiftUI public struct LivenessCaptureInstructionsView: View { - @Environment(\.modalMode) private var modalMode @State private var showSelfieCaptureView: Bool = false private let showAttribution: Bool - private let viewModel: SelfieViewModelV2 + private let viewModel: EnhancedSmartSelfieViewModel - public init(showAttribution: Bool, viewModel: SelfieViewModelV2) { + public init(showAttribution: Bool, viewModel: EnhancedSmartSelfieViewModel) { self.showAttribution = showAttribution self.viewModel = viewModel } public var body: some View { VStack { - HStack { - Button { - self.modalMode.wrappedValue = false - } label: { - Text(SmileIDResourcesHelper.localizedString(for: "Action.Cancel")) - .foregroundColor(SmileID.theme.accent) - } - Spacer() - } - ZStack { LottieView { - try await DotLottieFile.named("instructions_no_progress", bundle: SmileIDResourcesHelper.bundle) + try await DotLottieFile + .named( + "instruction_screen_with_side_bar", + bundle: SmileIDResourcesHelper.bundle + ) } .playing(loopMode: .loop) .frame(width: 235, height: 235) @@ -37,13 +30,14 @@ public struct LivenessCaptureInstructionsView: View { Text(SmileIDResourcesHelper.localizedString(for: "Instructions.SelfieCapture")) .multilineTextAlignment(.center) .font(SmileID.theme.header4) + .lineSpacing(4) .foregroundColor(SmileID.theme.tertiary) Spacer() VStack(spacing: 20) { NavigationLink( - destination: SelfieCaptureScreenV2( + destination: EnhancedSelfieCaptureScreen( viewModel: viewModel, showAttribution: showAttribution ), diff --git a/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreenV2.swift b/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedEnhancedSelfieCaptureScreen.swift similarity index 86% rename from Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreenV2.swift rename to Sources/SmileID/Classes/SelfieCapture/View/OrchestratedEnhancedSelfieCaptureScreen.swift index d78716078..6cd1b67cd 100644 --- a/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedSelfieCaptureScreenV2.swift +++ b/Sources/SmileID/Classes/SelfieCapture/View/OrchestratedEnhancedSelfieCaptureScreen.swift @@ -3,14 +3,12 @@ import SwiftUI /// Orchestrates the selfie capture flow - navigates between instructions, requesting permissions, /// showing camera view, and displaying processing screen -public struct OrchestratedSelfieCaptureScreenV2: View { +public struct OrchestratedEnhancedSelfieCaptureScreen: View { public let allowAgentMode: Bool public let showAttribution: Bool public let showInstructions: Bool public let onResult: SmartSelfieResultDelegate - private let viewModel: SelfieViewModelV2 - - private var originalBrightness = UIScreen.main.brightness + private let viewModel: EnhancedSmartSelfieViewModel public init( userId: String, @@ -29,7 +27,7 @@ public struct OrchestratedSelfieCaptureScreenV2: View { self.showAttribution = showAttribution self.showInstructions = showInstructions self.onResult = onResult - self.viewModel = SelfieViewModelV2( + self.viewModel = EnhancedSmartSelfieViewModel( isEnroll: isEnroll, userId: userId, jobId: jobId, @@ -49,7 +47,7 @@ public struct OrchestratedSelfieCaptureScreenV2: View { viewModel: viewModel ) } else { - SelfieCaptureScreenV2( + EnhancedSelfieCaptureScreen( viewModel: viewModel, showAttribution: showAttribution ) diff --git a/Sources/SmileID/Classes/SmileID.swift b/Sources/SmileID/Classes/SmileID.swift index 07aaa38c0..3572650f2 100644 --- a/Sources/SmileID/Classes/SmileID.swift +++ b/Sources/SmileID/Classes/SmileID.swift @@ -309,7 +309,7 @@ public class SmileID { delegate: SmartSelfieResultDelegate ) -> some View { if useStrictMode { - OrchestratedSelfieCaptureScreenV2( + OrchestratedEnhancedSelfieCaptureScreen( userId: userId, jobId: jobId, isEnroll: true, @@ -371,7 +371,7 @@ public class SmileID { delegate: SmartSelfieResultDelegate ) -> some View { if useStrictMode { - OrchestratedSelfieCaptureScreenV2( + OrchestratedEnhancedSelfieCaptureScreen( userId: userId, jobId: jobId, isEnroll: true, diff --git a/Sources/SmileID/Classes/Util.swift b/Sources/SmileID/Classes/Util.swift index 64563c110..bb2c23b3a 100644 --- a/Sources/SmileID/Classes/Util.swift +++ b/Sources/SmileID/Classes/Util.swift @@ -79,6 +79,10 @@ func toErrorMessage(error: SmileIDError) -> (String, String?) { return (error.localizedDescription, nil) case let .httpError(_, message): return ("", message) + case let .fileNotFound(message): + return (message, nil) + case let .unknown(message): + return (message, nil) default: return ("Confirmation.FailureReason", nil) } diff --git a/Sources/SmileID/Resources/Fonts/DMSans-Bold.ttf b/Sources/SmileID/Resources/Fonts/DMSans-Bold.ttf new file mode 100644 index 000000000..4f5412dc8 Binary files /dev/null and b/Sources/SmileID/Resources/Fonts/DMSans-Bold.ttf differ diff --git a/Sources/SmileID/Resources/Fonts/DMSans-Medium.ttf b/Sources/SmileID/Resources/Fonts/DMSans-Medium.ttf new file mode 100644 index 000000000..841d31d03 Binary files /dev/null and b/Sources/SmileID/Resources/Fonts/DMSans-Medium.ttf differ diff --git a/Sources/SmileID/Resources/Fonts/DMSans-Regular.ttf b/Sources/SmileID/Resources/Fonts/DMSans-Regular.ttf new file mode 100644 index 000000000..07266ae18 Binary files /dev/null and b/Sources/SmileID/Resources/Fonts/DMSans-Regular.ttf differ diff --git a/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings b/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings index a0049efc6..5ae2b1b7f 100644 --- a/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings +++ b/Sources/SmileID/Resources/Localization/en.lproj/Localizable.strings @@ -24,11 +24,12 @@ "Instructions.Quality" = "Move to well lit area and clear face of obstructions"; "Instructions.Brightness" = "Move to a well lit room"; "Instructions.Start" = "Put your face inside the oval frame and wait until it turns green"; -"Instructions.SelfieCapture" = "Position you head in camera frame. \nThen move in the direction that is indicated"; +"Instructions.SelfieCapture" = "Position your head in camera frame. \nThen move in the direction that is indicated."; "Instructions.PositionHeadInView" = "Position your head in view"; "Instructions.TurnHeadLeft" = "Turn your head to the left"; "Instructions.TurnHeadRight" = "Turn your head to the right"; "Instructions.TurnHeadUp" = "Turn your head slightly up"; +"Instructions.TurnPhoneUp" = "Turn your phone to portrait mode"; "Instructions.Document.Front.Header" = "Submit Front of ID"; "Instructions.Document.Front.Callout" = "We'll use it to verify your identity. Please follow the instructions below"; "Instructions.Document.GoodLightBody" = "Make sure your ID image is taken in a well-lit environment. Ensure the ID is clear and visible."; diff --git a/Sources/SmileID/Resources/LottieFiles/device_orientation.lottie b/Sources/SmileID/Resources/LottieFiles/device_orientation.lottie new file mode 100644 index 000000000..7bcdd86e9 Binary files /dev/null and b/Sources/SmileID/Resources/LottieFiles/device_orientation.lottie differ diff --git a/Sources/SmileID/Resources/LottieFiles/instruction_screen_with_side_bar.lottie b/Sources/SmileID/Resources/LottieFiles/instruction_screen_with_side_bar.lottie new file mode 100644 index 000000000..792d9345e Binary files /dev/null and b/Sources/SmileID/Resources/LottieFiles/instruction_screen_with_side_bar.lottie differ diff --git a/Sources/SmileID/Resources/LottieFiles/instructions_no_progress.lottie b/Sources/SmileID/Resources/LottieFiles/instructions_no_progress.lottie deleted file mode 100644 index 3207aaa70..000000000 Binary files a/Sources/SmileID/Resources/LottieFiles/instructions_no_progress.lottie and /dev/null differ diff --git a/Sources/SmileID/Resources/LottieFiles/light_animation_with_bg.lottie b/Sources/SmileID/Resources/LottieFiles/light_animation_with_bg.lottie new file mode 100644 index 000000000..56784698c Binary files /dev/null and b/Sources/SmileID/Resources/LottieFiles/light_animation_with_bg.lottie differ diff --git a/Sources/SmileID/Resources/LottieFiles/liveness_guides_with_bg.lottie b/Sources/SmileID/Resources/LottieFiles/liveness_guides_with_bg.lottie new file mode 100644 index 000000000..53c54f9db Binary files /dev/null and b/Sources/SmileID/Resources/LottieFiles/liveness_guides_with_bg.lottie differ