diff --git a/Sources/Rownd/Rownd.swift b/Sources/Rownd/Rownd.swift index bb65f84..4d5849e 100644 --- a/Sources/Rownd/Rownd.swift +++ b/Sources/Rownd/Rownd.swift @@ -180,6 +180,28 @@ public class Rownd: NSObject { inst.displayHub(.signIn, jsFnOptions: jsFnOptions) } + @MainActor + internal static func requestSignInForcedConversion(_ signInOptions: RowndSignInOptions?) { + inst.bottomSheetController.isUserDismissalDisabled = true + requestSignIn(signInOptions) + } + + @MainActor + internal static func releaseForcedConversionLock() { + let wasLocked = inst.bottomSheetController.isUserDismissalDisabled + inst.bottomSheetController.isUserDismissalDisabled = false + guard wasLocked else { return } + // The post-auth auto-close may have fired and been blocked while the lock was held + // (Hub schedules hide() 1.5s after `.authentication` but `authLevel` propagates from a + // separate UserData.fetch — the timer can lose the race). Retry the close now. + (inst.bottomSheetController.controller as? HubViewProtocol)?.hide() + } + + @MainActor + internal static var _bottomSheetIsLocked: Bool { + inst.bottomSheetController.isUserDismissalDisabled + } + @MainActor public static func connectAuthenticator( with: RowndConnectSignInHint, completion: (() -> Void)? = nil diff --git a/Sources/Rownd/Views/BottomSheetViewController.swift b/Sources/Rownd/Views/BottomSheetViewController.swift index 4b5092b..613be38 100644 --- a/Sources/Rownd/Views/BottomSheetViewController.swift +++ b/Sources/Rownd/Views/BottomSheetViewController.swift @@ -24,6 +24,26 @@ class BottomSheetViewController: UIViewController { var latestTargetHeight: CGFloat = 0.9 var isKeyboardOpen = false + // When true, swipe-to-dismiss and tap-outside-to-dismiss are disabled for this presentation, + // and the Hub's `can_touch_background_to_dismiss` message cannot re-enable them. Programmatic + // dismissal (e.g. after a successful sign-in) is unaffected. Reset on viewDidDisappear. + // The didSet applies the change to a live sheetController, so toggling the lock after + // presentation still takes effect (and unlock restores the Hub's most recent preference). + var isUserDismissalDisabled: Bool = false { + didSet { + guard isUserDismissalDisabled != oldValue, let sheetController = sheetController else { return } + if isUserDismissalDisabled { + sheetController.setCanTouchDimmingBackgroundToDismiss(false) + } else { + sheetController.setCanTouchDimmingBackgroundToDismiss(hubRequestedCanTouchToDismiss) + } + } + } + + // Tracks the most recent Hub-requested dismissibility state so that releasing the lock + // restores whatever the Hub last asked for instead of unconditionally re-enabling. + private var hubRequestedCanTouchToDismiss: Bool = true + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard let controller = controller else { @@ -54,6 +74,9 @@ class BottomSheetViewController: UIViewController { sheetController = presentAsBottomSheet(controller, theme: theme, behavior: behavior) + if isUserDismissalDisabled { + sheetController?.setCanTouchDimmingBackgroundToDismiss(false) + } } override func viewDidLoad() { @@ -69,6 +92,8 @@ class BottomSheetViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) self.controller = nil + self.isUserDismissalDisabled = false + self.hubRequestedCanTouchToDismiss = true } func updateBottomSheetHeight(_ number: CGFloat) { @@ -77,6 +102,10 @@ class BottomSheetViewController: UIViewController { } public func hideBottomSheet(_ completion: (() -> Void)? = nil) { + if isUserDismissalDisabled { + logger.debug("Ignoring hideBottomSheet: forced-conversion lock is active") + return + } sheetController?.dismiss(completion) } @@ -99,6 +128,10 @@ class BottomSheetViewController: UIViewController { } func canTouchDimmingBackgroundToDismiss(_ enable: Bool) { + hubRequestedCanTouchToDismiss = enable + if isUserDismissalDisabled && enable { + return + } if let sheetController = sheetController { sheetController.setCanTouchDimmingBackgroundToDismiss(enable) } diff --git a/Sources/Rownd/Views/HubViewController.swift b/Sources/Rownd/Views/HubViewController.swift index a391e63..66b2c5d 100644 --- a/Sources/Rownd/Views/HubViewController.swift +++ b/Sources/Rownd/Views/HubViewController.swift @@ -182,11 +182,16 @@ public class HubViewController: UIViewController, HubViewProtocol, BottomSheetHo self.dismiss(animated: true) return } - + + if bottomSheetController.isUserDismissalDisabled { + logger.debug("Ignoring HubViewController.hide(): forced-conversion lock is active") + return + } + if (isBottomSheetDismissing) { return } - + isBottomSheetDismissing = true bottomSheetController.hideBottomSheet({ self.dismiss(animated: true) diff --git a/Sources/Rownd/framework/InstantUsers.swift b/Sources/Rownd/framework/InstantUsers.swift index 63ca434..96c6e0e 100644 --- a/Sources/Rownd/framework/InstantUsers.swift +++ b/Sources/Rownd/framework/InstantUsers.swift @@ -11,6 +11,7 @@ import Combine class InstantUsers { private let context: Context private var cancellables = Set() + private var hasTriggeredConversion: Bool = false init( context: Context @@ -36,24 +37,26 @@ class InstantUsers { .removeDuplicates( by: == ) - .first { - isAuthenticated, - authLevel in - isAuthenticated && authLevel == .instant - } - .sink { - isAuthenticated, - authLevel in - - var signInOptions = RowndSignInOptions() - signInOptions.title = "Add a sign-in method" - signInOptions.subtitle = "To make sure you can always access your account, please add a sign-in method." - signInOptions.intent = .signUp - Rownd - .requestSignIn(signInOptions) - - subscriber - .unsubscribe() + .sink { [weak self] isAuthenticated, authLevel in + guard let self = self, isAuthenticated else { return } + + if !self.hasTriggeredConversion && authLevel == .instant { + self.hasTriggeredConversion = true + + var signInOptions = RowndSignInOptions() + signInOptions.title = "Add a sign-in method" + signInOptions.subtitle = "To make sure you can always access your account, please add a sign-in method." + signInOptions.intent = .signUp + Rownd.requestSignInForcedConversion(signInOptions) + return + } + + // User has converted to a non-instant auth level (verified, unverified, guest). + // Release the lock so the Hub's post-success auto-close can proceed. + if self.hasTriggeredConversion && authLevel != .instant && authLevel != .unknown { + Rownd.releaseForcedConversionLock() + subscriber.unsubscribe() + } }.store( in: &self.cancellables ) diff --git a/Tests/RowndTests/InstantUsersTests.swift b/Tests/RowndTests/InstantUsersTests.swift index bd09073..7ed2267 100644 --- a/Tests/RowndTests/InstantUsersTests.swift +++ b/Tests/RowndTests/InstantUsersTests.swift @@ -24,6 +24,8 @@ class InstantUsersTests: XCTestCase { override func tearDown() { Rownd.config = originalConfig + // Reset the singleton lock so it does not leak between tests. + Rownd.releaseForcedConversionLock() super.tearDown() } @@ -197,4 +199,125 @@ class InstantUsersTests: XCTestCase { _ = instantUsers } + + /// Verifies that the forced-conversion lock is engaged on the singleton bottom + /// sheet when the conversion subscription fires for an instant user. + func testLockIsEngagedWhenConversionTriggers() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Pre-condition: lock should start cleared") + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + XCTAssertTrue(Rownd._bottomSheetIsLocked, "Lock should engage when authLevel transitions to .instant") + + _ = instantUsers + } + + /// Verifies that the lock releases once the user transitions from `.instant` + /// to a non-instant identifier auth level (e.g. `.verified`). + func testLockReleasesAfterVerifiedConversion() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + + // Simulate successful conversion: user-data fetch returns with verified level. + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_verified_user", "email": "user@example.com"], + authLevel: .verified + ))) + + try await waitUntil(timeout: 2.0) { !Rownd._bottomSheetIsLocked } + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Lock should release once authLevel becomes .verified") + + _ = instantUsers + } + + /// Verifies that the `hasTriggeredConversion` gate prevents the conversion + /// flow from re-triggering after a successful conversion + lock release. + /// (The customer-confirmed behavior is once-per-session.) + func testConversionDoesNotRetriggerAfterRelease() async throws { + let store = createStore() + _ = Context(store) + + Rownd.config.forceInstantUserConversion = true + + let instantUsers = InstantUsers(context: Context.currentContext) + instantUsers.tmpForceInstantUserConversionIfRequested() + + try await Task.sleep(nanoseconds: 50_000_000) + + // First .instant → lock should engage. + store.dispatch(SetAuthState(payload: AuthState( + accessToken: generateJwt(expires: Date(timeIntervalSinceNow: 3600).timeIntervalSince1970), + refreshToken: generateJwt(expires: Date(timeIntervalSinceNow: 36000).timeIntervalSince1970) + ))) + store.dispatch(SetClockSync(clockSyncState: .synced)) + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_instant_user"], + authLevel: .instant + ))) + try await waitUntil(timeout: 2.0) { Rownd._bottomSheetIsLocked } + + // Convert and release. + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_user", "email": "user@example.com"], + authLevel: .verified + ))) + try await waitUntil(timeout: 2.0) { !Rownd._bottomSheetIsLocked } + + // Drop back to .instant — should NOT re-engage the lock (once-per-session). + store.dispatch(SetUserState(payload: UserState( + data: ["user_id": "test_user"], + authLevel: .instant + ))) + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertFalse(Rownd._bottomSheetIsLocked, "Conversion must not re-trigger after a successful release") + + _ = instantUsers + } + + // MARK: - helpers + + private func waitUntil(timeout: TimeInterval, condition: @escaping () -> Bool) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return } + try await Task.sleep(nanoseconds: 25_000_000) + } + } }