Files
simplex-chat/apps/ios/spec/client/navigation.md
T

18 KiB

SimpleX Chat iOS -- Navigation Architecture

Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay.

Related specs: Chat List | Chat View | State Management | README Related product: Product Overview

Source: ContentView.swift | NewChatView.swift | SettingsView.swift | OnboardingView.swift | UserProfilesView.swift


Table of Contents

  1. Overview
  2. Root View -- ContentView
  3. Navigation Stack
  4. Sheet Presentation
  5. Deep Linking
  6. Call Overlay
  7. Authentication Gate
  8. Onboarding Flow

1. Overview

The app's navigation follows a hierarchical model with a single navigation stack rooted in ContentView. Modal sheets and full-screen overlays augment the primary navigation path.

SimpleXApp
└── ContentView (root)
    ├── Authentication gate (LocalAuthView / SetAppPasscodeView)
    ├── Onboarding flow (if first launch / migration)
    ├── Main content
    │   └── NavigationStack / NavigationView
    │       ├── ChatListView (root of stack)
    │       │   ├── ChatView (pushed)
    │       │   │   ├── ChatInfoView / GroupChatInfoView (pushed)
    │       │   │   └── ChatItemInfoView (pushed)
    │       │   └── ContactConnectionInfo (pushed)
    │       └── Settings views (pushed)
    ├── Sheets (modal)
    │   ├── UserPicker
    │   ├── NewChatView
    │   ├── WhatsNew / Notices
    │   └── Settings sub-views
    └── Overlays (always on top)
        ├── Active call banner (when call active)
        └── ActiveCallView (full-screen call)

2. Root View -- ContentView

File: Shared/ContentView.swift

ContentView is the root view injected by SimpleXApp. It manages:

Environment

  • @EnvironmentObject var chatModel: ChatModel
  • @EnvironmentObject var theme: AppTheme
  • @Environment(\.scenePhase) var scenePhase

Key State

Property Type Purpose
contentAccessAuthenticationExtended Bool Passed at init to avoid re-render timing issues
automaticAuthenticationAttempted Bool Whether biometric auth was auto-attempted
waitingForOrPassedAuth Bool Whether auth gate should show
chatListUserPickerSheet UserPickerSheet? Active user picker sheet

View Selection Logic

// Simplified decision tree in ContentView.body:
if !prefPerformLA || accessAuthenticated {
    contentView()   // Main app content
} else {
    lockButton()    // Authentication required
}

The contentView() function further decides:

  • If chatModel.onboardingStage != .onboardingComplete: show onboarding
  • If chatModel.migrationState != nil: show migration UI
  • Otherwise: show ChatListView in a navigation container

3. Navigation Stack

iOS Version Compatibility

File: Shared/Views/Helpers/NavStackCompat.swift

The app supports iOS 15+ and uses a compatibility wrapper (NavStackCompat):

// NavStackCompat provides:
// - NavigationStack (iOS 16+): programmatic navigation via NavigationPath
// - NavigationView (iOS 15): classic NavigationLink-based navigation

Primary Navigation Path

ChatListView
    │
    ├─[tap chat]─→ ChatView
    │                  │
    │                  ├─[tap info]─→ ChatInfoView (direct)
    │                  │              └─→ VerifyCodeView, etc.
    │                  │
    │                  ├─[tap info]─→ GroupChatInfoView (group)
    │                  │              ├─→ GroupMemberInfoView
    │                  │              ├─→ GroupProfileView
    │                  │              └─→ GroupLinkView
    │                  │
    │                  └─[tap message info]─→ ChatItemInfoView
    │
    ├─[tap connection]─→ ContactConnectionInfo
    │
    └─[settings]─→ SettingsView
                       ├─→ NotificationsView
                       ├─→ NetworkAndServers
                       ├─→ AppearanceSettings
                       ├─→ PrivacySettings
                       ├─→ DatabaseView
                       └─→ UserProfilesView

Navigation Trigger

Chat navigation is triggered by setting ChatModel.chatId:

// In ChatListNavLink:
ItemsModel.shared.loadOpenChat(chatId) {
    // This sets ChatModel.chatId = chatId after a 250ms delay
    // allowing navigation animation to start smoothly
}

4. Sheet Presentation

Sheets are presented modally on top of the navigation stack:

Sheet Trigger Content
UserPicker Tap user avatar in nav bar User list, settings shortcuts
NewChatView Tap FAB / "+" button Create link, scan QR, paste link, new group
WhatsNew App update detected Release notes
AddGroupView "New Group" action Group creation wizard
ConnectDesktopView Settings > Desktop Remote desktop pairing
MigrateFromDevice Settings > Migration Device export
MigrateToDevice Onboarding migration Device import
LocalAuthView App foreground after background Biometric/passcode auth

Sheet Management

Sheets use SwiftUI .sheet(item:) or .sheet(isPresented:) modifiers on ContentView and ChatListView. Some sheets use the centralized AppSheetState.shared observable for coordination:

class AppSheetState: ObservableObject {
    static let shared = AppSheetState()
    var scenePhaseActive: Bool = false
    // ... sheet state coordination
}

5. Deep Linking

When the user taps a notification:

  1. NtfManager.processNotificationResponse() extracts the chatId from notification payload
  2. If a different user: calls changeActiveUser(userId:)
  3. Sets ChatModel.chatId = chatId to navigate to the conversation
  4. If the app was in background: the notification response is stored in ChatModel.notificationResponse and processed when the app becomes active

SimpleX links (simplex:/chat#...) are handled via connectViaUrl():

.onOpenURL { url in
    if AppChatState.shared.value == .active {
        chatModel.appOpenUrl = url      // Process immediately
    } else {
        chatModel.appOpenUrlLater = url // Process when active
    }
}

URL processing routes to the appropriate connection flow (join group, add contact, etc.) via planAndConnect().

App links use the app's own scheme, simplexchat:, for everything that is not a connection link. connectViaUrl_() branches on that scheme before the path switch, so an app link never reaches planAndConnect(), and openAppLink() dispatches on its path. A badge link (simplexchat:/badge/code/<code>) goes to openBadgeLink() in BadgesRedeemCodeView.swift, which presents BadgesRedeemLinkView as an app sheet. The sheet names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On Add badge it redeems the code into that profile and then shows BadgesView; Cancel sends nothing. If that profile already shows a badge (read from BadgeModel when the sheet renders), which core would refuse, the sheet does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under Show code), and offers View your badge; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, Dismiss or a swipe closes the sheet without cancelling anything; if the redemption then succeeds with the sheet closed or covered, an alert says the badge was added. While the sheet shows a redemption in flight (isBadgeLinkIssuing()), connectViaUrl_() ignores a further app link; otherwise it dismisses the sheets as for any link, which replaces an earlier badge link sheet. Any other path is a link type from a later version, and gets an alert asking to check for app updates. An app link can carry a secret, so onOpenURL logs it as "app link" rather than the URL.

Call invitations from notifications:

  1. NtfManager detects ntfActionAcceptCall action
  2. Sets ChatModel.ntfCallInvitationAction = (chatId, .accept)
  3. ContentView picks up the pending action and initiates the call

6. Call Overlay

The call UI overlays the entire app when a call is active:

Call Banner

When ChatModel.activeCall != nil and call is in connecting/active state:

  • A banner appears at the top of ContentView (height: callTopPadding = 40)
  • Shows contact name, call duration, tap to return to full-screen call
  • Main content is padded down to accommodate the banner

Full-Screen Call View

When ChatModel.showCallView == true:

  • ActiveCallView covers the entire screen as a ZStack overlay
  • Contains local/remote video, controls (mute, camera, speaker, end)
  • PiP mode: ChatModel.activeCallViewIsCollapsed collapses to mini view
  • Call view is always rendered on top of navigation and sheets
// In ContentView.allViews():
ZStack {
    contentView()
        .padding(.top, showCallArea ? callTopPadding : 0)

    if showCallArea, let call = chatModel.activeCall {
        VStack {
            activeCallInteractiveArea(call)
            Spacer()
        }
    }

    if chatModel.showCallView, let call = chatModel.activeCall {
        callView(call)  // Full screen overlay
    }
}

7. Authentication Gate

Local Authentication

When DEFAULT_PERFORM_LA is enabled:

  1. App enters background: chatModel.contentViewAccessAuthenticated = false
  2. App returns to foreground: ContentView shows lockButton() instead of content
  3. User taps lock button: LocalAuthView presented
  4. On successful auth: chatModel.contentViewAccessAuthenticated = true, content revealed

Authentication Methods

  • Face ID / Touch ID (via LocalAuthentication framework)
  • Custom numeric passcode
  • Custom alphanumeric passcode

Extended Authentication

  • After successful auth, a grace period prevents re-auth for brief background/foreground cycles (unlockedRecently())
  • contentAccessAuthenticationExtended is computed at ContentView.init to avoid render-time race conditions
  • The enteredBackgroundAuthenticated timestamp tracks when the app was last authenticated in background

8. Onboarding Flow

First-launch experience controlled by ChatModel.onboardingStage:

enum OnboardingStage: String, Identifiable {
    case step1_SimpleXInfo              // Welcome screen
    case step2_CreateProfile            // deprecated
    case step3_CreateSimpleXAddress     // deprecated
    case step3_ChooseServerOperators    // Choose server operators
    case step4_SetNotificationsMode     // Set notification preferences
    case onboardingComplete             // Normal operation
}

Each stage is a dedicated view presented in place of ChatListView within ContentView.

Migration state (ChatModel.migrationState != nil) takes precedence over onboarding.


9. Channel Creation Flow (AddChannelView)

Source: Shared/Views/NewChat/AddChannelView.swift

Entry Point

NewChatMenuButton includes a NavigationLink "Create public channel" with antenna icon, navigating to AddChannelView.

Three-Step Wizard

Step Function Description
1. Profile profileStepView() Channel name input (channelNameTextField()), profile image picker. "Configure relays" link to NetworkAndServers. Validates via canCreateProfile() (non-empty + valid display name) and checkHasRelays().
2. Progress progressStepView(_:) Relay connection progress with RelayProgressIndicator (circular active/total or spinner). Expandable relay list with relayStatusIndicator(_:) (green/red/orange dots). Cancel via cancelChannelCreation(_:) which calls apiDeleteChat.
3. Link linkStepView(_:) Wraps GroupLinkView(isChannel: true) for channel link sharing.

Key Functions

Function Scope Description
createChannel() private Calls apiNewPublicGroup(incognito:relayIds:groupProfile:), sets ChannelRelaysModel
getEnabledRelays() private Filters enabled/non-deleted relays, selects random 3
checkHasRelays() private Validates at least one relay exists
relayDisplayName(_:) module name > domain > link host > fallback
relayStatusIndicator(_:) module Green/red/orange dot + status text
RelayProgressIndicator module Circular progress (active/total) or spinner

10. Relay URL Interception

Source: Shared/ContentView.swift

In connectViaUrl_(), relay address links (URL path /r) are intercepted before processing:

if path == "/r" {
    showAlert(NSLocalizedString("Relay address", ...),
              message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", ...))
    return
}

Similarly, in planAndConnect() (NewChatView.swift), .simplexLink(_, .relay, _, _) patterns trigger the same alert and block connection.

11. Channel-Specific NewChatView Behavior

Source: Shared/Views/NewChat/NewChatView.swift

Prepared Group Alert (showPrepareGroupAlert)

When groupShortLinkInfo?.direct == false (channel relay link), the prepare alert uses:

  • Channel icon: antenna.radiowaves.left.and.right.circle.fill
  • Title: "Open channel"
  • Error: "Error opening channel"
  • apiPrepareGroup call passes directLink: false
  • Stores groupShortLinkInfo.groupRelays in ChatModel.shared.channelRelayHostnames

For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options.

Known Group Alert (showOpenKnownGroupAlert)

For channels (groupInfo.useRelays): the title is "Open channel"; for groups, "Open group"; business chats keep "Open chat" / "Open new chat". Unless the chat is merely prepared (nextConnectPrepared) or a business chat, the alert shows an information line with the user's membership role (memberRoleInformation) in the secondary color: subscriber/contributor for channels, observer/member for groups, moderator/admin/owner for both.


Source Files

File Path
Root view Shared/ContentView.swift
App entry point Shared/SimpleXApp.swift
Navigation compat Shared/Views/Helpers/NavStackCompat.swift
Chat list (nav root) Shared/Views/ChatList/ChatListView.swift
Nav link wrapper Shared/Views/ChatList/ChatListNavLink.swift
User picker Shared/Views/ChatList/UserPicker.swift
New chat view Shared/Views/NewChat/NewChatView.swift
Channel creation Shared/Views/NewChat/AddChannelView.swift
New chat menu Shared/Views/NewChat/NewChatMenuButton.swift
Settings view Shared/Views/UserSettings/SettingsView.swift
User profiles Shared/Views/UserSettings/UserProfilesView.swift
Onboarding view Shared/Views/Onboarding/OnboardingView.swift
Active call view Shared/Views/Call/ActiveCallView.swift
Local auth view Shared/Views/LocalAuth/LocalAuthView.swift
Notification manager Shared/Model/NtfManager.swift