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
- Overview
- Root View -- ContentView
- Navigation Stack
- Sheet Presentation
- Deep Linking
- Call Overlay
- Authentication Gate
- 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
ChatListViewin 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
Notification Deep Link
When the user taps a notification:
NtfManager.processNotificationResponse()extracts thechatIdfrom notification payload- If a different user: calls
changeActiveUser(userId:) - Sets
ChatModel.chatId = chatIdto navigate to the conversation - If the app was in background: the notification response is stored in
ChatModel.notificationResponseand processed when the app becomes active
URL Deep Link
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 Deep Link
Call invitations from notifications:
NtfManagerdetectsntfActionAcceptCallaction- Sets
ChatModel.ntfCallInvitationAction = (chatId, .accept) ContentViewpicks 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:
ActiveCallViewcovers the entire screen as a ZStack overlay- Contains local/remote video, controls (mute, camera, speaker, end)
- PiP mode:
ChatModel.activeCallViewIsCollapsedcollapses 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:
- App enters background:
chatModel.contentViewAccessAuthenticated = false - App returns to foreground:
ContentViewshowslockButton()instead of content - User taps lock button:
LocalAuthViewpresented - On successful auth:
chatModel.contentViewAccessAuthenticated = true, content revealed
Authentication Methods
- Face ID / Touch ID (via
LocalAuthenticationframework) - Custom numeric passcode
- Custom alphanumeric passcode
Extended Authentication
- After successful auth, a grace period prevents re-auth for brief background/foreground cycles (
unlockedRecently()) contentAccessAuthenticationExtendedis computed atContentView.initto avoid render-time race conditions- The
enteredBackgroundAuthenticatedtimestamp 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"
apiPrepareGroupcall passesdirectLink: false- Stores
groupShortLinkInfo.groupRelaysinChatModel.shared.channelRelayHostnames
Own Link Confirmation (showOwnGroupLinkConfirmConnectSheet)
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 |