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
- 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().
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 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 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"
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): 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 |