Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ABW-3692] Option to require biometrics check #1286

Merged
merged 15 commits into from
Sep 6, 2024
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@

// MARK: - LocalAuthenticationClient

/// A client for querying if passcode and biometrics are set up.
public struct LocalAuthenticationClient: Sendable {
/// The return value (`LocalAuthenticationConfig`) might be `nil` if app goes to background or stuff like that.
public typealias QueryConfig = @Sendable () throws -> LocalAuthenticationConfig

public var queryConfig: QueryConfig
public var authenticateWithBiometrics: AuthenticateWithBiometrics
public var setAuthenticatedSuccessfully: SetAuthenticatedSuccessfully
public var authenticatedSuccessfully: AuthenticatedSuccessfully

public init(queryConfig: @escaping QueryConfig) {
public init(
queryConfig: @escaping QueryConfig,
authenticateWithBiometrics: @escaping AuthenticateWithBiometrics,
setAuthenticatedSuccessfully: @escaping SetAuthenticatedSuccessfully,
authenticatedSuccessfully: @escaping AuthenticatedSuccessfully
) {
self.queryConfig = queryConfig
self.authenticateWithBiometrics = authenticateWithBiometrics
self.setAuthenticatedSuccessfully = setAuthenticatedSuccessfully
self.authenticatedSuccessfully = authenticatedSuccessfully
}
}

extension LocalAuthenticationClient {
/// The return value (`LocalAuthenticationConfig`) might be `nil` if app goes to background or stuff like that.
public typealias QueryConfig = @Sendable () throws -> LocalAuthenticationConfig
public typealias AuthenticateWithBiometrics = @Sendable () async throws -> Bool
public typealias SetAuthenticatedSuccessfully = @Sendable () -> Void
public typealias AuthenticatedSuccessfully = @Sendable () -> AnyAsyncSequence<Void>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import LocalAuthentication

// MARK: - LocalAuthenticationClient + DependencyKey
extension LocalAuthenticationClient: DependencyKey {
public static let liveValue: Self = .init(
queryConfig: {
try LAContext().queryLocalAuthenticationConfig()
}
)
public static let liveValue: Self = {
let authenticatedSuccessfully = AsyncPassthroughSubject<Void>()

return .init(
queryConfig: {
try LAContext().queryLocalAuthenticationConfig()
},
authenticateWithBiometrics: {
try await LAContext().authenticateWithBiometrics()
},
setAuthenticatedSuccessfully: { authenticatedSuccessfully.send(()) },
authenticatedSuccessfully: { authenticatedSuccessfully.eraseToAnyAsyncSequence() }
)
}()
}

// MARK: - LocalAuthenticationClient.Error
Expand Down Expand Up @@ -76,6 +85,21 @@ extension LAContext {
}
}

fileprivate func authenticateWithBiometrics() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in
evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: L10n.Biometrics.Prompt.title
) { success, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: success)
}
}
}
}

// Returns `nil` if user presses "cancel" button
fileprivate func queryLocalAuthenticationConfig() throws -> LocalAuthenticationConfig {
let passcodeSupportedResult = try evaluateIfPasscodeIsSetUp()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ extension DependencyValues {
// MARK: - LocalAuthenticationClient + TestDependencyKey
extension LocalAuthenticationClient: TestDependencyKey {
public static let testValue = Self(
queryConfig: { .biometricsAndPasscodeSetUp }
queryConfig: { .biometricsAndPasscodeSetUp },
authenticateWithBiometrics: { true },
setAuthenticatedSuccessfully: unimplemented("\(Self.self).setAuthenticatedSuccessfully"),
authenticatedSuccessfully: unimplemented("\(Self.self).authenticatedSuccessfully")
)
}
4 changes: 1 addition & 3 deletions RadixWallet/Core/DesignSystem/Components/PlainListRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,7 @@ struct PlainListRowCore: View {
private extension PlainListRowCore.ViewState {
var titleTextStyle: TextStyle {
switch context {
case .toggle:
.secondaryHeader
case .settings, .dappAndPersona:
case .toggle, .settings, .dappAndPersona:
.body1Header
Comment on lines -195 to 196
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The title font for the toggle context didn't match the design.

case .hiddenPersona:
.body1HighImportance
Expand Down
24 changes: 22 additions & 2 deletions RadixWallet/Core/DesignSystem/ToggleView.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@

// MARK: - ToggleView
public struct ToggleView: SwiftUI.View {
public let context: Context
public let icon: ImageAsset?
public let title: String
public let subtitle: String
public let minHeight: CGFloat
public let isOn: Binding<Bool>

public init(
context: Context = .toggle,
icon: ImageAsset? = nil,
title: String,
subtitle: String,
minHeight: CGFloat = .largeButtonHeight,
isOn: Binding<Bool>
) {
self.context = context
self.icon = icon
self.title = title
self.subtitle = subtitle
Expand All @@ -30,10 +33,27 @@ public struct ToggleView: SwiftUI.View {
.padding(.trailing, .medium3)
}

PlainListRowCore(context: .toggle, title: title, subtitle: subtitle)
PlainListRowCore(context: context.plainListRowContext, title: title, subtitle: subtitle)
}
}
)
.frame(maxWidth: .infinity, minHeight: minHeight)
}
}

// MARK: ToggleView.Context
extension ToggleView {
public enum Context {
case settings
case toggle
}
}

extension ToggleView.Context {
var plainListRowContext: PlainListRowCore.ViewState.Context {
switch self {
case .settings: .settings
case .toggle: .toggle
}
}
Comment on lines +53 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this ?

PlainListRowCore.ViewState.Context sent to PlainListRow should always be .toggle.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PlainListRowCore.ViewState.Context sent to PlainListRow should always be .toggle.

For toggle views in Preferences, we need to use .settings since they have a different design in that context.

I explicitly defined ToggleView.Context (instead of directly passing PlainListRowCore.ViewState.Context) to make it clear which contexts are supported by ToggleView. This prevents the mistaken use of unsupported contexts, such as .dappAndPersona, under the assumption that they are valid states and would be handled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I see now, thanks for explanation

}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ public enum AssetResource {
public static let token = ImageAsset(name: "token")
public static let unknownComponent = ImageAsset(name: "unknown-component")
public static let xrd = ImageAsset(name: "xrd")
public static let advancedLock = ImageAsset(name: "advancedLock")
public static let appSettings = ImageAsset(name: "appSettings")
public static let authorizedDapps = ImageAsset(name: "authorized-dapps")
public static let backups = ImageAsset(name: "backups")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "advancedLock.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
52 changes: 49 additions & 3 deletions RadixWallet/Features/AppFeature/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI
public final class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject {
public weak var windowScene: UIWindowScene?
public var overlayWindow: UIWindow?
private var didEnterBackground = false

public func scene(
_ scene: UIScene,
Expand All @@ -18,21 +19,44 @@ public final class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObj
{
overlayWindow(in: windowScene)
}

// avoids unimplemented("LocalAuthenticationClient.authenticatedSuccessfully")
if !_XCTIsTesting {
@Dependency(\.localAuthenticationClient) var localAuthenticationClient
Task { @MainActor in
for try await _ in localAuthenticationClient.authenticatedSuccessfully() {
hideBiometricsSplashWindow()
}
}
}
}

public func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
@Dependency(\.appsFlyerClient) var appsFlyerClient
appsFlyerClient.continue(userActivity)
}

public func sceneWillEnterForeground(_ scene: UIScene) {
guard didEnterBackground, let scene = scene as? UIWindowScene else { return }

if #unavailable(iOS 18) {
showBiometricsSplashWindow(in: scene)
}
hidePrivacyProtectionWindow()
}

public func sceneDidEnterBackground(_ scene: UIScene) {
guard let scene = scene as? UIWindowScene else { return }

didEnterBackground = true

if #unavailable(iOS 18) {
hideBiometricsSplashWindow()
}
showPrivacyProtectionWindow(in: scene)
}

public func sceneWillEnterForeground(_ scene: UIScene) {
hidePrivacyProtectionWindow()
}
// MARK: Overlay

func overlayWindow(in scene: UIWindowScene) {
let overlayView = OverlayReducer.View(
Expand All @@ -58,6 +82,28 @@ public final class SceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObj
self.overlayWindow = overlayWindow
}

// MARK: Biometrics

private var biometricsSplashWindow: UIWindow?

private func showBiometricsSplashWindow(in scene: UIWindowScene) {
let splashView = Splash.View(
GhenadieVP marked this conversation as resolved.
Show resolved Hide resolved
store: .init(
initialState: .init(context: .appForegrounded),
reducer: Splash.init
))

biometricsSplashWindow = UIWindow(windowScene: scene)
biometricsSplashWindow?.rootViewController = UIHostingController(rootView: splashView)
biometricsSplashWindow?.windowLevel = .normal + 2
biometricsSplashWindow?.makeKeyAndVisible()
}

private func hideBiometricsSplashWindow() {
biometricsSplashWindow?.isHidden = true
biometricsSplashWindow = nil
}

// MARK: Privacy Protection

private var privacyProtectionWindow: UIWindow?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
extension Preferences.State {
var viewState: Preferences.ViewState {
let isDeveloperModeEnabled = appPreferences?.security.isDeveloperModeEnabled ?? false
let isAdvancedLockEnabled = appPreferences?.security.isAdvancedLockEnabled ?? false
#if DEBUG
return .init(
isDeveloperModeEnabled: isDeveloperModeEnabled,
isAdvancedLockEnabled: isAdvancedLockEnabled,
exportLogsUrl: exportLogsUrl
)
#else
return .init(
isDeveloperModeEnabled: isDeveloperModeEnabled
isDeveloperModeEnabled: isDeveloperModeEnabled,
isAdvancedLockEnabled: isAdvancedLockEnabled
)
#endif
}
Expand All @@ -19,15 +22,25 @@ extension Preferences.State {
public extension Preferences {
struct ViewState: Equatable {
let isDeveloperModeEnabled: Bool
let isAdvancedLockEnabled: Bool
#if DEBUG
let exportLogsUrl: URL?
init(isDeveloperModeEnabled: Bool, exportLogsUrl: URL?) {
init(
isDeveloperModeEnabled: Bool,
isAdvancedLockEnabled: Bool,
exportLogsUrl: URL?
) {
self.isDeveloperModeEnabled = isDeveloperModeEnabled
self.isAdvancedLockEnabled = isAdvancedLockEnabled
self.exportLogsUrl = exportLogsUrl
}
#else
init(isDeveloperModeEnabled: Bool) {
init(
isDeveloperModeEnabled: Bool,
isAdvancedLockEnabled: Bool
) {
self.isDeveloperModeEnabled = isDeveloperModeEnabled
self.isAdvancedLockEnabled = isAdvancedLockEnabled
}
#endif
}
Expand Down Expand Up @@ -57,12 +70,10 @@ extension Preferences.View {
WithViewStore(store, observe: \.viewState, send: { .view($0) }) { viewStore in
ScrollView {
VStack(spacing: .zero) {
ForEachStatic(rows) { kind in
ForEachStatic(rows(viewStore: viewStore)) { kind in
SettingsRow(kind: kind, store: store)
}

developerMode(viewStore: viewStore)

#if DEBUG
exportLogs(viewStore: viewStore)
#endif
Expand All @@ -76,8 +87,23 @@ extension Preferences.View {
}

@MainActor
private var rows: [SettingsRow<Preferences>.Kind] {
[
private func rows(viewStore: ViewStoreOf<Preferences>) -> [SettingsRow<Preferences>.Kind] {
let advancedLockToggle: SettingsRow<Preferences>.Kind? = if #unavailable(iOS 18) {
.toggleModel(
icon: AssetResource.advancedLock,
title: L10n.Preferences.AdvancedLock.title,
subtitle: L10n.Preferences.AdvancedLock.subtitle,
minHeight: .zero,
isOn: viewStore.binding(
get: \.isAdvancedLockEnabled,
send: { .advancedLockToogled($0) }
)
)
} else {
nil
}

return [
.separator,
.model(
title: L10n.Preferences.DepositGuarantees.title,
Expand All @@ -98,30 +124,24 @@ extension Preferences.View {
icon: .systemImage("eye.fill"),
action: .hiddenAssetsButtonTapped
),
advancedLockToggle,
.header(L10n.Preferences.advancedPreferences),
.model(
title: L10n.Preferences.gateways,
icon: .asset(AssetResource.gateway),
action: .gatewaysButtonTapped
),
]
}

private func developerMode(viewStore: ViewStoreOf<Preferences>) -> some View {
ToggleView(
icon: AssetResource.developerMode,
title: L10n.Preferences.DeveloperMode.title,
subtitle: L10n.Preferences.DeveloperMode.subtitle,
minHeight: .zero,
isOn: viewStore.binding(
get: \.isDeveloperModeEnabled,
send: { .developerModeToogled($0) }
)
)
.padding(.horizontal, .medium3)
.padding(.vertical, .medium1)
.background(Color.app.white)
.withSeparator
.toggleModel(
icon: AssetResource.developerMode,
title: L10n.Preferences.DeveloperMode.title,
subtitle: L10n.Preferences.DeveloperMode.subtitle,
minHeight: .zero,
isOn: viewStore.binding(
get: \.isDeveloperModeEnabled,
send: { .developerModeToogled($0) }
)
),
].compactMap { $0 }
}

#if DEBUG
Expand Down
Loading
Loading