diff --git a/CHANGELOG.md b/CHANGELOG.md index 45543e16..8860a83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ directly impact users rather than highlighting other crucial architectural updat ## [Unreleased] ### Added -- Flexa integrated into Zashi, users can pay with ZEC for Flexa codes. +- Flexa integrated into Zashi, users can pay with ZEC for Flexa codes. +- Authentication for the app launch and cold starts after 15 minutes. ### Fixed - Splash screen animation is blocked by the main thread on iOS 16 and older. diff --git a/modules/Package.swift b/modules/Package.swift index e8041fcf..921077ba 100644 --- a/modules/Package.swift +++ b/modules/Package.swift @@ -820,10 +820,12 @@ let package = Package( "BalanceFormatter", "DerivationTool", "Generated", + "LocalAuthenticationHandler", "NumberFormatter", "SupportDataGenerator", "Utils", - "ZcashSDKEnvironment" + "ZcashSDKEnvironment", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture") ], path: "Sources/UIComponents" ), diff --git a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationInterface.swift b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationInterface.swift index f03dcbc7..752550ba 100644 --- a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationInterface.swift +++ b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationInterface.swift @@ -16,5 +16,13 @@ extension DependencyValues { @DependencyClient public struct LocalAuthenticationClient { + public enum Method: Equatable { + case faceID + case none + case passcode + case touchID + } + public let authenticate: @Sendable () async -> Bool + public let method: @Sendable () -> Method } diff --git a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationLiveKey.swift b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationLiveKey.swift index 548f8a45..4c7959b2 100644 --- a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationLiveKey.swift +++ b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationLiveKey.swift @@ -33,6 +33,29 @@ extension LocalAuthenticationClient: DependencyKey { /// Some interruption occurred during the authentication, access to the sensitive content is therefore forbidden return false } + }, + method: { + let context = LAContext() + var error: NSError? + + if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + switch context.biometryType { + case .faceID: + return .faceID + case .touchID: + return .touchID + case .none, .opticID: + return .none + @unknown default: + return .none + } + } else { + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + return .passcode + } else { + return .none + } + } } ) } diff --git a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationMocks.swift b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationMocks.swift index c13a45c1..905014d6 100644 --- a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationMocks.swift +++ b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationMocks.swift @@ -7,10 +7,12 @@ extension LocalAuthenticationClient { public static let mockAuthenticationSucceeded = Self( - authenticate: { true } + authenticate: { true }, + method: { .none } ) public static let mockAuthenticationFailed = Self( - authenticate: { false } + authenticate: { false }, + method: { .none } ) } diff --git a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationTestKey.swift b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationTestKey.swift index a956795d..86bd3b0a 100644 --- a/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationTestKey.swift +++ b/modules/Sources/Dependencies/LocalAuthenticationHandler/LocalAuthenticationTestKey.swift @@ -10,6 +10,7 @@ import XCTestDynamicOverlay extension LocalAuthenticationClient: TestDependencyKey { public static let testValue = Self( - authenticate: unimplemented("\(Self.self).authenticate", placeholder: false) + authenticate: unimplemented("\(Self.self).authenticate", placeholder: false), + method: unimplemented("\(Self.self).method", placeholder: .none) ) } diff --git a/modules/Sources/Features/Root/RootDestination.swift b/modules/Sources/Features/Root/RootDestination.swift index 7a021dbe..d84160bc 100644 --- a/modules/Sources/Features/Root/RootDestination.swift +++ b/modules/Sources/Features/Root/RootDestination.swift @@ -134,6 +134,7 @@ extension Root { case .splashFinished: state.splashAppeared = true + state.lastAuthenticationTimestamp = Int(Date().timeIntervalSince1970) exchangeRate.refreshExchangeRateUSD() return .none diff --git a/modules/Sources/Features/Root/RootInitialization.swift b/modules/Sources/Features/Root/RootInitialization.swift index 9c6d189f..0edcc562 100644 --- a/modules/Sources/Features/Root/RootInitialization.swift +++ b/modules/Sources/Features/Root/RootInitialization.swift @@ -17,6 +17,7 @@ import Utils extension Root { public enum Constants { static let udIsRestoringWallet = "udIsRestoringWallet" + static let noAuthenticationWithinXMinutes = 15 } public enum InitializationAction: Equatable { @@ -59,6 +60,14 @@ extension Root { ) case .initialization(.appDelegate(.willEnterForeground)): + if state.featureFlags.appLaunchBiometric { + let now = Date() + let before = Date.init(timeIntervalSince1970: TimeInterval(state.lastAuthenticationTimestamp)) + if let xMinutesAgo = Calendar.current.date(byAdding: .minute, value: -Constants.noAuthenticationWithinXMinutes, to: now), + before < xMinutesAgo { + state.splashAppeared = false + } + } state.appStartState = .willEnterForeground if state.isLockedInKeychainUnavailableState || !sdkSynchronizer.latestState().syncStatus.isPrepared { return .send(.initialization(.initialSetups)) diff --git a/modules/Sources/Features/Root/RootStore.swift b/modules/Sources/Features/Root/RootStore.swift index 4f36a700..ec3b3b3f 100644 --- a/modules/Sources/Features/Root/RootStore.swift +++ b/modules/Sources/Features/Root/RootStore.swift @@ -57,8 +57,10 @@ public struct Root { public var deeplinkWarningState: DeeplinkWarning.State = .initial public var destinationState: DestinationState public var exportLogsState: ExportLogs.State + @Shared(.inMemory(.featureFlags)) public var featureFlags: FeatureFlags = .initial public var isLockedInKeychainUnavailableState = false public var isRestoringWallet = false + @Shared(.appStorage(.lastAuthenticationTimestamp)) public var lastAuthenticationTimestamp: Int = 0 public var notEnoughFreeSpaceState: NotEnoughFreeSpace.State public var onboardingState: OnboardingFlow.State public var phraseDisplayState: RecoveryPhraseDisplay.State diff --git a/modules/Sources/Generated/L10n.swift b/modules/Sources/Generated/L10n.swift index 5ac73036..167c2775 100644 --- a/modules/Sources/Generated/L10n.swift +++ b/modules/Sources/Generated/L10n.swift @@ -788,6 +788,16 @@ public enum L10n { } } } + public enum Splash { + /// Tap the face icon to use Face ID and unlock it. + public static let authFaceID = L10n.tr("Localizable", "splash.authFaceID", fallback: "Tap the face icon to use Face ID and unlock it.") + /// Tap the key icon to enter your passcode and unlock it. + public static let authPasscode = L10n.tr("Localizable", "splash.authPasscode", fallback: "Tap the key icon to enter your passcode and unlock it.") + /// Your Zashi account is secured. + public static let authTitle = L10n.tr("Localizable", "splash.authTitle", fallback: "Your Zashi account is secured.") + /// Tap the print icon to use Touch ID and unlock it. + public static let authTouchID = L10n.tr("Localizable", "splash.authTouchID", fallback: "Tap the print icon to use Touch ID and unlock it.") + } public enum SupportData { public enum AppVersionItem { /// App identifier diff --git a/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/Contents.json b/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/Contents.json new file mode 100644 index 00000000..91bdb7c6 --- /dev/null +++ b/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "key.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/key.png b/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/key.png new file mode 100644 index 00000000..eda17e1e Binary files /dev/null and b/modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/key.png differ diff --git a/modules/Sources/Generated/Resources/Localizable.strings b/modules/Sources/Generated/Resources/Localizable.strings index b913ab71..e45d1ba3 100644 --- a/modules/Sources/Generated/Resources/Localizable.strings +++ b/modules/Sources/Generated/Resources/Localizable.strings @@ -390,6 +390,12 @@ Sharing this private data is irrevocable — once you have shared this private d "deeplinkWarning.cta" = "Rescan in Zashi"; "deeplinkWarning.screenTitle" = "Hello!"; +// MARK: - Splash Screen +"splash.authTitle" = "Your Zashi account is secured."; +"splash.authFaceID" = "Tap the face icon to use Face ID and unlock it."; +"splash.authTouchID" = "Tap the print icon to use Touch ID and unlock it."; +"splash.authPasscode" = "Tap the key icon to enter your passcode and unlock it."; + // MARK: - Tooltips "tooltip.exchangeRate.title" = "Exchange rate unavailable"; "tooltip.exchangeRate.desc" = "We tried but we couldn’t refresh the exchange rate for you. Check your connection, relaunch the app, and we’ll try again."; diff --git a/modules/Sources/Generated/SharedStateKeys.swift b/modules/Sources/Generated/SharedStateKeys.swift index b8e77dc8..996febd4 100644 --- a/modules/Sources/Generated/SharedStateKeys.swift +++ b/modules/Sources/Generated/SharedStateKeys.swift @@ -15,4 +15,5 @@ public extension String { static let addressBookContacts = "sharedStateKey_addressBookContacts" static let toast = "sharedStateKey_toast" static let featureFlags = "sharedStateKey_featureFlags" + static let lastAuthenticationTimestamp = "sharedStateKey_lastAuthenticationTimestamp" } diff --git a/modules/Sources/Generated/XCAssets+Generated.swift b/modules/Sources/Generated/XCAssets+Generated.swift index ea39799b..5ce9d8ba 100644 --- a/modules/Sources/Generated/XCAssets+Generated.swift +++ b/modules/Sources/Generated/XCAssets+Generated.swift @@ -54,6 +54,7 @@ public enum Asset { public static let flyReceivedFilled = ImageAsset(name: "flyReceivedFilled") public enum Icons { public static let alertCircle = ImageAsset(name: "alertCircle") + public static let authKey = ImageAsset(name: "authKey") public static let coinsHand = ImageAsset(name: "coinsHand") public static let currencyDollar = ImageAsset(name: "currencyDollar") public static let currencyZec = ImageAsset(name: "currencyZec") diff --git a/modules/Sources/Models/FeatureFlags.swift b/modules/Sources/Models/FeatureFlags.swift index 057c81b2..592948a5 100644 --- a/modules/Sources/Models/FeatureFlags.swift +++ b/modules/Sources/Models/FeatureFlags.swift @@ -7,11 +7,14 @@ public struct FeatureFlags: Equatable { public let flexa: Bool - + public let appLaunchBiometric: Bool + init( - flexa: Bool = false + flexa: Bool = false, + appLaunchBiometric: Bool = false ) { self.flexa = flexa + self.appLaunchBiometric = appLaunchBiometric } } @@ -27,11 +30,13 @@ private extension FeatureFlags { FeatureFlags.disabled #elseif SECANT_TESTNET FeatureFlags( - flexa: false + flexa: false, + appLaunchBiometric: true ) #else FeatureFlags( - flexa: true + flexa: true, + appLaunchBiometric: true ) #endif } diff --git a/modules/Sources/UIComponents/Overlays/SplashView.swift b/modules/Sources/UIComponents/Overlays/SplashView.swift index b8341cfa..b7aca413 100644 --- a/modules/Sources/UIComponents/Overlays/SplashView.swift +++ b/modules/Sources/UIComponents/Overlays/SplashView.swift @@ -7,6 +7,8 @@ import SwiftUI import Generated +import LocalAuthenticationHandler +import ComposableArchitecture final class SplashManager: ObservableObject { struct SplashShape: Shape { @@ -29,8 +31,10 @@ final class SplashManager: ObservableObject { var task: Task<(), Never>? var currentMaxHeight: CGFloat = 0.0 var step: CGFloat = 0.0 + @Published var authenticationDidntSucceed = false @Published var isOn = true let completion: () -> Void + var timer: Timer? init(_ isHidden: Bool, completion: @escaping () -> Void) { self.isHidden = isHidden @@ -39,12 +43,31 @@ final class SplashManager: ObservableObject { if !isHidden { preparePoints() - self.spinTheWheel() + authenticate() + } + } + + func authenticate() { + @Dependency(\.localAuthentication) var localAuthentication + + authenticationDidntSucceed = false + + Task { + if await !localAuthentication.authenticate() { + await self.authenticationFailed() + } else { + await self.spinTheWheel() + } } } - func spinTheWheel() { - Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { timer in + @MainActor func authenticationFailed() { + authenticationDidntSucceed = true + } + + @MainActor func spinTheWheel() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { timer in if self.isOn { Task { await self.tick() @@ -125,31 +148,88 @@ final class SplashManager: ObservableObject { struct SplashView: View { @StateObject var splashManager: SplashManager let isHidden: Bool - + var authenticationIcon: Image { + @Dependency(\.localAuthentication) var localAuthentication + + switch localAuthentication.method() { + case .faceID: return Image(systemName: "faceid") + case .touchID: return Image(systemName: "touchid") + case .passcode: return Asset.Assets.Icons.authKey.image + default: return Asset.Assets.Icons.coinsHand.image + } + } + + var authenticationDesc: String { + @Dependency(\.localAuthentication) var localAuthentication + + switch localAuthentication.method() { + case .faceID: return L10n.Splash.authFaceID + case .touchID: return L10n.Splash.authTouchID + case .passcode: return L10n.Splash.authPasscode + default: return "" + } + } + var body: some View { if splashManager.isOn && !isHidden { - GeometryReader { proxy in - Asset.Assets.zashiLogo.image - .zImage(width: 249, height: 321, color: .white) - .scaleEffect(0.35) - .position( - x: proxy.frame(in: .local).midX, - y: proxy.frame(in: .local).midY * 0.5 - ) + ZStack { + GeometryReader { proxy in + Asset.Assets.zashiLogo.image + .zImage(width: 249, height: 321, color: .white) + .scaleEffect(0.35) + .position( + x: proxy.frame(in: .local).midX, + y: proxy.frame(in: .local).midY * 0.5 + ) + + Asset.Assets.splashHi.image + .zImage(width: 246, height: 213, color: .white) + .scaleEffect(0.35) + .position( + x: proxy.frame(in: .local).midX, + y: proxy.frame(in: .local).midY * 0.8 + ) + } + .background(Asset.Colors.splash.color) + .mask { + SplashManager.SplashShape(points: splashManager.points) + } + .ignoresSafeArea() + .onChange(of: isHidden) { value in + if value { + splashManager.preparePoints() + } + } + if splashManager.authenticationDidntSucceed { + VStack(spacing: 0) { + Spacer() + + Button { + splashManager.authenticate() + } label: { + authenticationIcon + .renderingMode(.template) + .resizable() + .frame(width: 64, height: 64) + .foregroundColor(.white) + } - Asset.Assets.splashHi.image - .zImage(width: 246, height: 213, color: .white) - .scaleEffect(0.35) - .position( - x: proxy.frame(in: .local).midX, - y: proxy.frame(in: .local).midY * 0.8 - ) + Text(L10n.Splash.authTitle) + .font(.custom(FontFamily.Inter.semiBold.name, size: 20)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.top, 24) + + Text(authenticationDesc) + .font(.custom(FontFamily.Inter.regular.name, size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + .padding(.bottom, 120) + .screenHorizontalPadding() + } } - .background(Asset.Colors.splash.color) - .mask { - SplashManager.SplashShape(points: splashManager.points) - } - .ignoresSafeArea() } } } @@ -161,12 +241,22 @@ struct SplashModifier: ViewModifier { func body(content: Content) -> some View { content .overlay { - SplashView( - splashManager: SplashManager(isHidden) { - completion() - }, - isHidden: isHidden - ) + if isHidden { + SplashView( + splashManager: SplashManager(isHidden) { + completion() + }, + isHidden: isHidden + ) + .hidden() + } else { + SplashView( + splashManager: SplashManager(isHidden) { + completion() + }, + isHidden: isHidden + ) + } } } } diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 4d27dd64..b935f621 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -2303,7 +2303,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "zashi-internal.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_ASSET_PATHS = "\"secant/Preview Content\""; DEVELOPMENT_TEAM = RLPRR8CPQG; @@ -2316,7 +2316,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.4.3; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "co.electriccoin.secant-testnet"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2334,7 +2334,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "zashi-internal.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"secant/Preview Content\""; DEVELOPMENT_TEAM = RLPRR8CPQG; ENABLE_BITCODE = NO; @@ -2346,7 +2346,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.4.3; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "co.electriccoin.secant-testnet"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2364,7 +2364,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "zashi-internal.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "\"secant/Preview Content\""; DEVELOPMENT_TEAM = RLPRR8CPQG; ENABLE_BITCODE = NO; @@ -2376,7 +2376,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.4.3; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "co.electriccoin.secant-testnet"; PRODUCT_NAME = "$(TARGET_NAME)";