Files
simplex-chat/apps/ios/spec/client/navigation.md
T
2026-03-05 09:13:24 +00:00

16 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().

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 channel (BETA)" 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 new 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): titles become "Open channel" / "Open new channel".


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