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

381 lines
16 KiB
Markdown

# SimpleX Chat iOS -- Navigation Architecture
> Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay.
>
> Related specs: [Chat List](chat-list.md) | [Chat View](chat-view.md) | [State Management](../state.md) | [README](../README.md)
> Related product: [Product Overview](../../product/README.md)
**Source:** [`ContentView.swift`](../../Shared/ContentView.swift) | [`NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | [`SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | [`OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | [`UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift)
---
## Table of Contents
1. [Overview](#1-overview)
2. [Root View -- ContentView](#2-root-view)
3. [Navigation Stack](#3-navigation-stack)
4. [Sheet Presentation](#4-sheet-presentation)
5. [Deep Linking](#5-deep-linking)
6. [Call Overlay](#6-call-overlay)
7. [Authentication Gate](#7-authentication-gate)
8. [Onboarding Flow](#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`](../../Shared/ContentView.swift#L24)
**File**: [`Shared/ContentView.swift`](../../Shared/ContentView.swift)
`ContentView` is the root view injected by `SimpleXApp`. It manages:
### [Environment](../../Shared/ContentView.swift#L25-L37)
- `@EnvironmentObject var chatModel: ChatModel`
- `@EnvironmentObject var theme: AppTheme`
- `@Environment(\.scenePhase) var scenePhase`
### [Key State](../../Shared/ContentView.swift#L35-L52)
| Property | Type | Purpose |
|----------|------|---------|
| [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) | `Bool` | Passed at init to avoid re-render timing issues |
| [`automaticAuthenticationAttempted`](../../Shared/ContentView.swift#L38) | `Bool` | Whether biometric auth was auto-attempted |
| [`waitingForOrPassedAuth`](../../Shared/ContentView.swift#L51) | `Bool` | Whether auth gate should show |
| [`chatListUserPickerSheet`](../../Shared/ContentView.swift#L52) | `UserPickerSheet?` | Active user picker sheet |
### [View Selection Logic](../../Shared/ContentView.swift#L60-L80)
```swift
// Simplified decision tree in ContentView.body:
if !prefPerformLA || accessAuthenticated {
contentView() // Main app content
} else {
lockButton() // Authentication required
}
```
The [`contentView()`](../../Shared/ContentView.swift#L169) function further decides:
- If `chatModel.onboardingStage != .onboardingComplete`: show [onboarding](../../Shared/ContentView.swift#L174)
- 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`](../../Shared/Views/Helpers/NavStackCompat.swift)
The app supports iOS 15+ and uses a compatibility wrapper ([`NavStackCompat`](../../Shared/Views/Helpers/NavStackCompat.swift#L11)):
```swift
// 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`:
```swift
// 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`](../../Shared/Views/NewChat/NewChatView.swift#L78) | 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](../../Shared/ContentView.swift#L95) | 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`](../../Shared/ContentView.swift#L29) observable for coordination:
```swift
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:
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
### [URL Deep Link](../../Shared/ContentView.swift#L281)
SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Shared/ContentView.swift#L439):
```swift
.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()`](../../Shared/Views/NewChat/NewChatView.swift#L1181).
### Call Deep Link
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](../../Shared/ContentView.swift#L203)
When `ChatModel.activeCall != nil` and call is in connecting/active state:
- A banner appears at the top of ContentView (height: [`callTopPadding = 40`](../../Shared/ContentView.swift#L54))
- 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](../../Shared/ContentView.swift#L185)
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
```swift
// 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](../../Shared/ContentView.swift#L359)
When [`DEFAULT_PERFORM_LA`](../../Shared/ContentView.swift#L44) is enabled:
1. App enters background: `chatModel.contentViewAccessAuthenticated = false`
2. App returns to foreground: `ContentView` shows [`lockButton()`](../../Shared/ContentView.swift#L238) instead of content
3. User taps lock button: [`LocalAuthView`](../../Shared/ContentView.swift#L95) 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](../../Shared/ContentView.swift#L351)
- After successful auth, a grace period prevents re-auth for brief background/foreground cycles ([`unlockedRecently()`](../../Shared/ContentView.swift#L351))
- [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) 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](../../Shared/Views/Onboarding/OnboardingView.swift#L13)
First-launch experience controlled by [`ChatModel.onboardingStage`](../../Shared/Views/Onboarding/OnboardingView.swift#L46):
```swift
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`](../../Shared/ContentView.swift#L174).
Migration state (`ChatModel.migrationState != nil`) takes precedence over onboarding.
---
## 9. Channel Creation Flow (`AddChannelView`)
**Source:** [`Shared/Views/NewChat/AddChannelView.swift`](../../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`](../../Shared/ContentView.swift#L454)
In `connectViaUrl_()`, relay address links (URL path `/r`) are intercepted before processing:
```swift
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`](../../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`
### 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`](../../Shared/ContentView.swift) |
| App entry point | `Shared/SimpleXApp.swift` |
| Navigation compat | [`Shared/Views/Helpers/NavStackCompat.swift`](../../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`](../../Shared/Views/NewChat/NewChatView.swift) |
| Channel creation | [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) |
| New chat menu | [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift) |
| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) |
| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) |
| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../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` |