Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Sources/Rownd/Rownd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +183 to +187
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Lock not applied live 🐞 Bug ≡ Correctness

requestSignInForcedConversion() sets isUserDismissalDisabled = true, but the actual bottom-sheet
dismissibility is only changed in BottomSheetViewController.viewWillAppear, so if the Hub is
already presented the sheet remains user-dismissible despite the lock.
Agent Prompt
### Issue description
Forced conversion sets a flag (`isUserDismissalDisabled`) but does not reliably apply it to an already-presented bottom sheet. The only place that calls `setCanTouchDimmingBackgroundToDismiss(false)` based on the flag is `BottomSheetViewController.viewWillAppear`, which won’t run again if the bottom sheet is already on-screen.

### Issue Context
`displayViewControllerOnTop` intentionally avoids re-presenting the bottom sheet when it’s already presented, so updating the lock must update the existing `sheetController` immediately.

### Fix Focus Areas
- Sources/Rownd/Rownd.swift[183-187]
- Sources/Rownd/Rownd.swift[431-447]
- Sources/Rownd/Views/BottomSheetViewController.swift[32-65]

### Suggested fix
Implement a `didSet` on `isUserDismissalDisabled` (or a dedicated method) that, when toggled, immediately calls `sheetController?.setCanTouchDimmingBackgroundToDismiss(...)` if `sheetController` already exists. This ensures the lock takes effect even when the Hub sheet is already presented.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9ea56d0. isUserDismissalDisabled now has a didSet that pushes through to the live sheetController via setCanTouchDimmingBackgroundToDismiss(...), so toggling the flag after presentation takes effect immediately. Same path covers your second comment — releasing the lock restores the Hub's most recent dismissibility request (tracked in a separate hubRequestedCanTouchToDismiss so we don't unconditionally force true on unlock).


@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
Expand Down
33 changes: 33 additions & 0 deletions Sources/Rownd/Views/BottomSheetViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,6 +74,9 @@ class BottomSheetViewController: UIViewController {

sheetController = presentAsBottomSheet(controller, theme: theme, behavior: behavior)

if isUserDismissalDisabled {
sheetController?.setCanTouchDimmingBackgroundToDismiss(false)
}
}

override func viewDidLoad() {
Expand All @@ -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) {
Expand All @@ -77,6 +102,10 @@ class BottomSheetViewController: UIViewController {
}

public func hideBottomSheet(_ completion: (() -> Void)? = nil) {
if isUserDismissalDisabled {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nit — completion silently dropped. When locked, the completion parameter is never invoked. Today the only caller is HubViewController.hide(), which is itself blocked one level up, so this doesn't fire in practice. But the signature still promises "call me back when the sheet is hidden," and a future caller could quietly wedge if they depend on that. Worth a doc comment at minimum, or have callers check isUserDismissalDisabled themselves and skip passing the completion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving as-is. The only caller passing a completion (HubViewController.hide()) is itself guarded by the lock check one level up, so the completion is never registered when the inner block fires. Worth revisiting if a second caller appears.

logger.debug("Ignoring hideBottomSheet: forced-conversion lock is active")
return
}
sheetController?.dismiss(completion)
}

Expand All @@ -99,6 +128,10 @@ class BottomSheetViewController: UIViewController {
}

func canTouchDimmingBackgroundToDismiss(_ enable: Bool) {
hubRequestedCanTouchToDismiss = enable
if isUserDismissalDisabled && enable {
return
}
if let sheetController = sheetController {
sheetController.setCanTouchDimmingBackgroundToDismiss(enable)
}
Expand Down
9 changes: 7 additions & 2 deletions Sources/Rownd/Views/HubViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,16 @@ public class HubViewController: UIViewController, HubViewProtocol, BottomSheetHo
self.dismiss(animated: true)
return
}


if bottomSheetController.isUserDismissalDisabled {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Bug — lock bypass. The new check guards hide(), but HubViewController.viewWillDisappear at line 145 (unchanged here) directly calls:

hostController.dismiss(animated: true)

…on the bottom-sheet host. That path neither checks isUserDismissalDisabled nor goes through hideBottomSheet, so any system event that disappears the Hub VC will tear the sheet down despite the lock. Easy reproductions: presenting another modal over the Hub, deep-linking to a flow that swaps the sheet, or anything calling dismiss on a parent.

Suggest adding the same early-return on the lock at line 141, or routing all dismissals through hideBottomSheet so there's one chokepoint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Leaving as-is per offline discussion. The viewWillDisappear path requires something to disappear the Hub VC from outside the SDK, which our customers shouldn't be doing on the forced-conversion flow. Worth revisiting if we see field reports of escape via this path.

logger.debug("Ignoring HubViewController.hide(): forced-conversion lock is active")
return
}

if (isBottomSheetDismissing) {
return
}

isBottomSheetDismissing = true
bottomSheetController.hideBottomSheet({
self.dismiss(animated: true)
Expand Down
39 changes: 21 additions & 18 deletions Sources/Rownd/framework/InstantUsers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Combine
class InstantUsers {
private let context: Context
private var cancellables = Set<AnyCancellable>()
private var hasTriggeredConversion: Bool = false

init(
context: Context
Expand All @@ -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 {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Race — sheet can stay open after successful conversion.

HubWebViewController dispatches the tokens on .authentication then schedules hide() 1.5s later:

store.dispatch(store.state.auth.onReceiveAuthTokens(
    AuthState(accessToken: authMessage.accessToken, refreshToken: authMessage.refreshToken)
))
...
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
    if initialJsFunctionArgsAsJson == self?.jsFunctionArgsAsJson {
        self?.hubViewController?.hide()
    }
}

The authLevel update comes from UserData.fetch(), not from the token dispatch. On a slow network (or any 1.5s+ fetch latency) the timeline is:

  1. t=0: tokens dispatched, authLevel still .instant
  2. t=1.5s: hide() fires → lock still set → blocked, sheet stays open
  3. t=2s+: UserData.fetch() returns, authLevel flips to .verified
  4. This sink fires, lock released — but nothing re-invokes hide(), so the sheet is now unlocked-but-still-open

The user sees a sheet that won't close on its own. They can swipe-dismiss it (because the lock is released), but that's an unexpected UX.

Suggest re-triggering dismissal when releasing the lock, e.g.:

Rownd.releaseForcedConversionLock()
// Re-attempt the post-auth close that may have been blocked by the lock.
(Rownd.inst.bottomSheetController.controller as? HubViewProtocol)?.hide()
subscriber.unsubscribe()

(Or add a releaseForcedConversionLockAndDismiss() helper that does both.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9ea56d0. Rownd.releaseForcedConversionLock now re-invokes hide() on the active HubViewProtocol after clearing the flag, so if the post-auth hide() timer fired during the locked window and was blocked, we close the sheet as soon as authLevel propagates and the lock releases. Guarded on wasLocked so calling release with no lock held is a no-op.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Question — .guest releases the lock. Going .instant.guest isn't really "adding an identifier" in the spirit of forceInstantUserConversion. If a customer's Hub config exposes a "continue as guest" path, the user could escape the forced flow without converting. Is that the intended behavior, or should the release condition be authLevel == .verified || authLevel == .unverified only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping current behavior (.guest releases the lock alongside .verified/.unverified). The contract of forceInstantUserConversion is "force the user out of the auto-assigned .instant state" — .guest is a deliberate user choice and treating it as a successful exit avoids stranding users when a customer's Hub config exposes a guest path. If a customer wants stricter enforcement, the right knob is disabling guest auth in Hub config.

Rownd.releaseForcedConversionLock()
subscriber.unsubscribe()
}
}.store(
in: &self.cancellables
)
Expand Down
123 changes: 123 additions & 0 deletions Tests/RowndTests/InstantUsersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
}
}
}
Loading