[#1375] App launch protected by device authentication method

- authentication added to the app start pipeline
- it's triggered for all fresh app launches
- it's required after 15minutes after cold starts
- recognizes available auth method and reflects it in the UI

[#1375] App launch protected by device authentication method

- final UI for the re-authenticate

[#1375] App launch protected by device authentication method

- Changelog updated
- Texts localized
This commit is contained in:
Lukas Korba 2024-10-17 17:55:23 +02:00
parent 571b501ef3
commit 60ec66ee91
18 changed files with 219 additions and 45 deletions

View File

@ -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.

View File

@ -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"
),

View File

@ -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
}

View File

@ -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
}
}
}
)
}

View File

@ -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 }
)
}

View File

@ -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)
)
}

View File

@ -134,6 +134,7 @@ extension Root {
case .splashFinished:
state.splashAppeared = true
state.lastAuthenticationTimestamp = Int(Date().timeIntervalSince1970)
exchangeRate.refreshExchangeRateUSD()
return .none

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "key.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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 couldnt refresh the exchange rate for you. Check your connection, relaunch the app, and well try again.";

View File

@ -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"
}

View File

@ -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")

View File

@ -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
}

View File

@ -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
)
}
}
}
}

View File

@ -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)";