[#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:
parent
571b501ef3
commit
60ec66ee91
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -134,6 +134,7 @@ extension Root {
|
|||
|
||||
case .splashFinished:
|
||||
state.splashAppeared = true
|
||||
state.lastAuthenticationTimestamp = Int(Date().timeIntervalSince1970)
|
||||
exchangeRate.refreshExchangeRateUSD()
|
||||
return .none
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
12
modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/Contents.json
vendored
Normal file
12
modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/Contents.json
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "key.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/key.png
vendored
Normal file
BIN
modules/Sources/Generated/Resources/Assets.xcassets/icons/authKey.imageset/key.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -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.";
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)";
|
||||
|
|
Loading…
Reference in New Issue