The desktop app polled full clipboard contents every second on the AWT event thread to update ChatModel.clipboardHasText. On X11, each read blocks up to sun.awt.datatransfer.timeout (10s) when the selection owner does not respond (e.g. after KeePassXC's clipboard auto-clear), making every click/scroll wait ~9s. clipboardHasText has had no readers since its only consumer was removed in #4398, so the whole mechanism (desktop poll, Android clip listener, onResume refresh, expect/actual, state field) is deleted. See plans/2026-07-11-fix-desktop-clipboard-freeze.md.
16 KiB
Navigation Specification
Source: common/src/commonMain/kotlin/chat/simplex/common/App.kt (470 lines)
Table of Contents
- Overview
- AppScreen Composable
- MainScreen
- Android Layout
- Desktop Layout
- ModalManager
- Authentication Gate
- Onboarding Flow
- Source Files
Executive Summary
SimpleX Chat navigation is a platform-adaptive system implemented in App.kt. The root AppScreen composable applies theming and safe-area insets, delegating to MainScreen which acts as a state machine routing between onboarding, authentication, database error, and the main chat interface. Android uses a 2-column sliding layout (AndroidScreen), while desktop uses a fixed 3-column layout (DesktopScreen). Modal presentation is managed by ModalManager, which provides named zones (start, center, end, fullscreen) for layered content. Authentication is gated by AppLock, and onboarding follows a linear OnboardingStage enum.
1. Overview
AppScreen (line 46)
+-- SimpleXTheme
+-- Surface
+-- MainScreen (line 82)
|-- [Migration in progress] -> DefaultProgressView
|-- [Database opening] -> DefaultProgressView
|-- [Database error] -> DatabaseErrorView
|-- [Encryption check pending] -> SplashView
|-- [Onboarding incomplete] -> AnimatedContent { OnboardingStage views }
|-- [Onboarding complete]
| |-- [Android]
| | +-- AndroidWrapInCallLayout
| | +-- AndroidScreen (line 293)
| | |-- StartPartOfScreen (ChatListView)
| | +-- ChatView (slide-in panel)
| +-- [Desktop]
| +-- DesktopScreen (line 406)
| |-- StartPartOfScreen + UserPicker (left column)
| |-- ModalManager.start (overlay on left)
| |-- CenterPartOfScreen / ChatView (center column)
| +-- ModalManager.end (right column)
|-- [Unauthorized] -> AuthView / SplashView / PasscodeView
|-- [Active call] -> ActiveCallView (desktop) / startCallActivity (Android)
+-- [Incoming call] -> IncomingCallAlertView
2. AppScreen Composable
Location: App.kt#L47
@Composable
fun AppScreen()
Responsibilities
- Theme application: Wraps content in
SimpleXThemewithSurfaceusingMaterialTheme.colors.background. - Window insets: Computes safe padding for landscape mode, accounting for display cutouts on both sides. Uses
WindowInsets.safeDrawingandWindowInsets.displayCutoutto calculate symmetric padding. - Fullscreen gallery overlay: When
chatModel.fullscreenGalleryVisibleis true, draws a black rectangle behind content extending into the cutout areas to provide an immersive gallery background. - Delegates to
MainScreen().
3. MainScreen
Location: App.kt#L84
@Composable
fun MainScreen()
State Machine
MainScreen evaluates a series of conditions in priority order:
| Priority | Condition | View |
|---|---|---|
| 1 | onboarding == Step1_SimpleXInfo && migrationState != null |
SimpleXInfo (migration in progress) |
| 2 | dbMigrationInProgress |
DefaultProgressView("Database migration...") |
| 3 | chatDbStatus == null && showInitializationView |
DefaultProgressView("Opening database...") |
| 4 | showChatDatabaseError |
DatabaseErrorView |
| 5 | chatDbEncrypted == null || localUserCreated == null |
SplashView |
| 6 | onboarding == OnboardingComplete |
Platform-specific main screen |
| 7 | Other onboarding stages | AnimatedContent with stage-specific views |
Onboarding Complete Branch (line ~156)
When onboarding is complete:
- Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call).
- Routes to
AndroidScreenorDesktopScreenbased on platform.
Overlay Layers (bottom of MainScreen)
| Layer | Condition | Content |
|---|---|---|
ModalManager.fullscreen |
Android + migration/onboarding | Fullscreen modals |
SwitchingUsersView |
User switch in progress | Loading overlay |
| Auth gate | userAuthorized != true |
AuthView or SplashView + passcode |
| Active call | showCallView == true |
ActiveCallView (desktop) or call activity (Android) |
| One-time passcode | Always | ModalManager.fullscreen.showOneTimePasscodeInView |
| Privacy alerts | Always | AlertManager.privacySensitive |
| Incoming call | activeCallInvitation != null |
IncomingCallAlertView |
| Shared alerts | Always | AlertManager.shared |
4. Android Layout
Location: App.kt#L296
@Composable
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>)
2-Column Slide Animation
Uses BoxWithConstraints to get maxWidth, then two Box containers:
- Left panel (StartPartOfScreen): Chat list, positioned at
translationX = -offset. - Right panel (ChatView): Chat view, positioned at
translationX = maxWidth - offset.
The offset is an Animatable<Float>:
0fwhen no chat is selected (chat list visible).maxWidth.valuewhen a chat is open (chat view visible).
Animation Flow
snapshotFlow { chatModel.chatId.value }detects chat ID changes.- When
chatIdbecomes null,onComposed(null)animates offset to 0. - When
ChatViewfinishes composing (callsonComposed(chatId)), offset animates tomaxWidth. - Animation uses
chatListAnimationSpec()(standard spring or tween).
Display Cutout Handling
If the device has a display cutout on horizontal sides (detected via WindowInsets.displayCutout), the panels are clipped with RectangleShape to prevent the chat list from showing through during transition.
Call Layout Wrapper
AndroidWrapInCallLayout (line ~279) adds a 40dp top padding when an active call is in progress (not in WaitCapabilities or InvitationAccepted state), with an ActiveCallInteractiveArea banner above.
5. Desktop Layout
Location: App.kt#L410
@Composable
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>)
3-Column Layout
| Column | Width | Content |
|---|---|---|
| Left | DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier (fixed) |
StartPartOfScreen (ChatListView) + UserPicker overlay |
| Left overlay | Same as left column | ModalManager.start modals + SwitchingUsersView |
| Center | min = DEFAULT_MIN_CENTER_MODAL_WIDTH, weight = 1f (flexible) |
CenterPartOfScreen (ChatView or "no selected chat" placeholder, or ModalManager.center) |
| Right | max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier (flexible, 0 when empty) |
ModalManager.end (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) |
Column Separators
VerticalDividerbetween left and center columns (always visible).VerticalDividerbetween center and right columns (visible whenModalManager.end.hasModalsOpen()).
Click-to-Dismiss Overlay
When the UserPicker is visible or a start modal is open (but no center modal), a full-size clickable overlay covers the center+right area (line ~428). Clicking it closes start modals and hides the UserPicker.
CenterPartOfScreen
Location: App.kt#L373
- When
chatIdis null and no center modals: shows "No selected chat" placeholder. - When
chatIdis null and center modals open: showsModalManager.center. - When
chatIdis set: showsChatView. - Automatically closes center modals when a chat is selected.
StartPartOfScreen
Location: App.kt#L352
Routes between:
SetDeliveryReceiptsView(ifchatModel.setDeliveryReceiptsis true)ChatListView(normal operation)ShareListView(whenchatModel.sharedContentis non-null, i.e., forwarding)
6. ModalManager
Location: common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt (line 92)
class ModalManager(private val placement: ModalPlacement?)
Zones
| Zone | Android Behavior | Desktop Behavior |
|---|---|---|
start |
Shared (same as all others) | Left column overlay, slides from start |
center |
Shared | Center column overlay, replaces ChatView |
end |
Shared | Right column, slides from end |
fullscreen |
Shared | Fullscreen overlay |
On Android, all four zones point to the same shared instance, meaning modals stack in a single overlay. On desktop, each zone is independent with its own ModalPlacement.
companion object {
val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START)
val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER)
val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END)
val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN)
}
Modal Stack
Each ModalManager maintains a stack of ModalViewHolder objects with:
id: ModalViewId?-- optional identifier for deduplicationanimated: Boolean-- whether to use enter/exit transitionsdata: ModalData-- scoped data for the modalmodal: @Composable ModalData.(close: () -> Unit) -> Unit-- the modal content
Key Methods
| Method | Description |
|---|---|
showModal |
Push a simple modal onto the stack |
showModalCloseable |
Push a modal with a close callback |
showCustomModal |
Push a modal with full control over ModalView wrapper |
closeModals |
Pop all modals from the stack |
closeModalsExceptFirst |
Pop all but the bottom modal |
hasModalsOpen() |
Check if any modals are on the stack |
showInView |
Render the current modal stack into the composable tree |
Usage Pattern
| Action | Zone Used |
|---|---|
| Settings, New Chat, User Address | ModalManager.start |
| Onboarding conditions, What's New | ModalManager.center |
| ChatInfoView, GroupChatInfoView, ChatItemInfoView, GroupMemberInfoView | ModalManager.end |
| Passcode entry, Call view, Migration | ModalManager.fullscreen |
7. Authentication Gate
Location: AppLock.kt#L17
object AppLock {
val userAuthorized = mutableStateOf<Boolean?>(null)
val enteredBackground = mutableStateOf<Long?>(null)
val laFailed = mutableStateOf(false)
}
State
| Field | Type | Description |
|---|---|---|
userAuthorized |
MutableState<Boolean?> |
null = not yet determined, true = authenticated, false = locked |
enteredBackground |
MutableState<Long?> |
Timestamp when app entered background (for lock delay) |
laFailed |
MutableState<Boolean> |
True if last authentication attempt failed |
Authentication Flow
- MainScreen checks
unauthorized(derived:userAuthorized.value != true) at line ~135. - If unauthorized and not in an active call:
- Launches
AppLock.runAuthenticate()which triggers platform-specific biometric/passcode prompt. - On Android with system auth finishing during activity destruction, authentication is skipped.
- Launches
- If
performLApreference is set andlaFailedis true: showsAuthViewwith "Unlock" button. - If
performLAis set andlaFailedis false: showsSplashViewwith passcode overlay.
Lock Delay
The laLockDelay preference controls how long after backgrounding the app requires re-authentication. When laLockDelay == 0, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth.
Lock Modes
LAMode.SYSTEM: Uses Android biometric/system lock screen.LAMode.PASSCODE: Uses in-app passcode (SetAppPasscodeView).
First-Time Lock Notice
showLANotice (line ~33 in AppLock.kt) prompts users to enable SimpleX Lock when they have more than 3 chats, have not yet been shown the notice, and have not enabled lock. On Android, it offers a choice between system auth and passcode.
8. Onboarding Flow
Location: common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt (line 3)
enum class OnboardingStage {
Step1_SimpleXInfo,
Step2_CreateProfile,
LinkAMobile,
Step2_5_SetupDatabasePassphrase,
Step3_ChooseServerOperators,
Step3_CreateSimpleXAddress,
Step4_SetNotificationsMode,
OnboardingComplete
}
Stage Progression
| Stage | View | Next Stage |
|---|---|---|
Step1_SimpleXInfo |
SimpleXInfo -- app introduction, privacy features |
Step2_CreateProfile or LinkAMobile (desktop) |
Step2_CreateProfile |
CreateFirstProfile -- display name, optional image |
Step2_5_SetupDatabasePassphrase or Step3_ChooseServerOperators |
LinkAMobile |
LinkAMobile -- desktop linking to mobile device |
Step2_CreateProfile |
Step2_5_SetupDatabasePassphrase |
SetupDatabasePassphrase -- optional DB encryption |
Step3_ChooseServerOperators |
Step3_ChooseServerOperators |
OnboardingConditionsView -- server operator selection, T&C |
Step3_CreateSimpleXAddress or Step4_SetNotificationsMode |
Step3_CreateSimpleXAddress |
SetNotificationsMode (legacy backcompat) |
Step4_SetNotificationsMode |
Step4_SetNotificationsMode |
SetNotificationsMode -- notification permission setup |
OnboardingComplete |
OnboardingComplete |
Main app screen | -- |
Animated Transitions
Onboarding uses AnimatedContent with directional transitions:
- Forward:
fromEndToStartTransition(slide left). - Backward:
fromStartToEndTransition(slide right).
The stage value is stored in appPrefs.onboardingStage and persisted across app restarts.
9. Source Files
| File | Description |
|---|---|
App.kt |
AppScreen, MainScreen, AndroidScreen, DesktopScreen, StartPartOfScreen, CenterPartOfScreen, EndPartOfScreen |
AppLock.kt |
AppLock object, authentication state, lock notice, LA mode selection |
views/helpers/ModalView.kt |
ModalManager class, ModalPlacement enum, modal stack management |
views/onboarding/OnboardingView.kt |
OnboardingStage enum |
views/onboarding/SimpleXInfo.kt |
Step 1: App introduction |
views/WelcomeView.kt |
Step 2: Profile creation (CreateFirstProfile) |
views/onboarding/LinkAMobileView.kt |
Desktop: Link a mobile device |
views/onboarding/SetupDatabasePassphrase.kt |
Step 2.5: Database passphrase |
views/onboarding/ChooseServerOperators.kt |
Step 3: Server operators and conditions |
views/onboarding/SetNotificationsMode.kt |
Step 4: Notification setup |
views/chatlist/ChatListView.kt |
Chat list (StartPartOfScreen content) |
views/chatlist/UserPicker.kt |
User switching panel |
views/chat/ChatView.kt |
Chat view (CenterPartOfScreen content) |
views/database/DatabaseErrorView.kt |
Database error recovery |
views/SplashView.kt |
Splash / loading screen |
views/call/CallView.kt |
In-call fullscreen view (ActiveCallView) |
views/localauth/PasswordEntry.kt |
Column divider utility (contains VerticalDivider) |