State Management
Table of Contents
- Overview
- ChatModel
- ChatsContext
- Chat
- AppPreferences
- Source Files
1. Overview
SimpleX Chat uses a singleton-based, Compose-reactive state model. The primary state holder is ChatModel, a Kotlin object annotated with @Stable. All mutable fields are Compose MutableState, MutableStateFlow, or SnapshotStateList/SnapshotStateMap instances, which trigger Compose recomposition on mutation.
There is no ViewModel layer, no dependency injection framework, and no Redux/MVI pattern. The architecture is:
State mutations originate from two sources:
- User actions: Compose UI handlers call
api*() suspend functions on ChatController, which send commands to the Haskell core, receive responses, and update ChatModel.
- Core events: The receiver coroutine (
startReceiver) calls processReceivedMsg(), which updates ChatModel fields on Dispatchers.Main.
2. ChatModel
Defined at ChatModel.kt line 86 as @Stable object ChatModel.
Controller Reference
| Field |
Type |
Line |
Purpose |
controller |
ChatController |
87 |
Reference to the ChatController singleton |
User State
| Field |
Type |
Line |
Purpose |
currentUser |
MutableState<User?> |
89 |
Currently active user profile |
users |
SnapshotStateList<UserInfo> |
90 |
All user profiles (multi-account) |
localUserCreated |
MutableState<Boolean?> |
91 |
Whether a local user has been created (null = unknown during init) |
setDeliveryReceipts |
MutableState<Boolean> |
88 |
Trigger for delivery receipts setup dialog |
switchingUsersAndHosts |
MutableState<Boolean> |
100 |
True while switching active user/remote host |
changingActiveUserMutex |
Mutex |
193 |
Prevents concurrent user switches |
Chat Runtime State
| Field |
Type |
Line |
Purpose |
chatRunning |
MutableState<Boolean?> |
92 |
null = initializing, true = running, false = stopped |
chatDbChanged |
MutableState<Boolean> |
93 |
Database was changed externally (needs restart) |
chatDbEncrypted |
MutableState<Boolean?> |
94 |
Whether database is encrypted |
chatDbStatus |
MutableState<DBMigrationResult?> |
95 |
Result of database migration attempt |
ctrlInitInProgress |
MutableState<Boolean> |
96 |
Controller initialization in progress |
dbMigrationInProgress |
MutableState<Boolean> |
97 |
Database migration in progress |
incompleteInitializedDbRemoved |
MutableState<Boolean> |
98 |
Tracks if incomplete DB files were removed (prevents infinite retry) |
Current Chat State
| Field |
Type |
Line |
Purpose |
chatId |
MutableState<String?> |
103 |
ID of the currently open chat (null = chat list shown) |
chatAgentConnId |
MutableState<String?> |
104 |
Agent connection ID for current chat |
chatSubStatus |
MutableState<SubscriptionStatus?> |
105 |
Subscription status for current chat |
openAroundItemId |
MutableState<Long?> |
106 |
Item ID to scroll to when opening chat |
chatsContext |
ChatsContext |
107 |
Primary chat context (see ChatsContext) |
secondaryChatsContext |
MutableState<ChatsContext?> |
108 |
Optional secondary context for dual-pane views |
chats |
State<List<Chat>> |
110 |
Derived from chatsContext.chats |
deletedChats |
MutableState<List<Pair<Long?, String>>> |
112 |
Recently deleted chats (rhId, chatId) |
Group Members
| Field |
Type |
Line |
Purpose |
groupMembers |
MutableState<List<GroupMember>> |
113 |
Members of currently viewed group |
groupMembersIndexes |
MutableState<Map<Long, Int>> |
114 |
Index lookup by groupMemberId |
membersLoaded |
MutableState<Boolean> |
115 |
Whether group members have been loaded |
Chat Tags and Filters
| Field |
Type |
Line |
Purpose |
userTags |
MutableState<List<ChatTag>> |
118 |
User-defined chat tags |
activeChatTagFilter |
MutableState<ActiveFilter?> |
119 |
Currently active filter in chat list |
presetTags |
SnapshotStateMap<PresetTagKind, Int> |
120 |
Counts for preset tag categories (favorites, groups, contacts, etc.) |
unreadTags |
SnapshotStateMap<Long, Int> |
121 |
Unread counts per user-defined tag |
Terminal and Developer
| Field |
Type |
Line |
Purpose |
terminalsVisible |
Set<Boolean> |
125 |
Tracks which terminal views are visible (default vs floating) |
terminalItems |
MutableState<List<TerminalItem>> |
126 |
Command/response log for developer terminal |
Calls (WebRTC)
| Field |
Type |
Line |
Purpose |
callManager |
CallManager |
161 |
WebRTC call lifecycle manager |
callInvitations |
SnapshotStateMap<String, RcvCallInvitation> |
162 |
Pending incoming call invitations keyed by chatId |
activeCallInvitation |
MutableState<RcvCallInvitation?> |
163 |
Currently displayed incoming call invitation |
activeCall |
MutableState<Call?> |
164 |
Currently active call |
activeCallViewIsVisible |
MutableState<Boolean> |
165 |
Whether call UI is showing |
activeCallViewIsCollapsed |
MutableState<Boolean> |
166 |
Whether call UI is in PiP/collapsed mode |
callCommand |
SnapshotStateList<WCallCommand> |
167 |
Pending WebRTC commands |
showCallView |
MutableState<Boolean> |
168 |
Call view visibility toggle |
switchingCall |
MutableState<Boolean> |
169 |
True during call switching |
Compose Draft and Sharing
| Field |
Type |
Line |
Purpose |
draft |
MutableState<ComposeState?> |
176 |
Saved compose draft for current chat |
draftChatId |
MutableState<String?> |
177 |
Chat ID the draft belongs to |
sharedContent |
MutableState<SharedContent?> |
180 |
Content received via share intent or internal forwarding |
Remote Hosts
| Field |
Type |
Line |
Purpose |
remoteHosts |
SnapshotStateList<RemoteHostInfo> |
199 |
Connected remote hosts (for desktop-mobile pairing) |
currentRemoteHost |
MutableState<RemoteHostInfo?> |
200 |
Currently selected remote host |
remoteHostPairing |
MutableState<Pair<RemoteHostInfo?, RemoteHostSessionState>?> |
203 |
Remote host pairing state |
remoteCtrlSession |
MutableState<RemoteCtrlSession?> |
204 |
Remote controller session |
Miscellaneous UI State
| Field |
Type |
Line |
Purpose |
userAddress |
MutableState<UserContactLinkRec?> |
127 |
User's public contact address |
chatItemTTL |
MutableState<ChatItemTTL> |
128 |
Chat item time-to-live setting |
clearOverlays |
MutableState<Boolean> |
131 |
Signal to close all overlays/modals |
appOpenUrl |
MutableState<Pair<Long?, String>?> |
137 |
URL opened via deep link (rhId, uri) |
appOpenUrlConnecting |
MutableState<Boolean> |
138 |
Whether a deep link connection is in progress |
newChatSheetVisible |
MutableState<Boolean> |
141 |
Whether new chat bottom sheet is visible |
fullscreenGalleryVisible |
MutableState<Boolean> |
144 |
Fullscreen gallery mode |
chatListBanner |
ChatListBanner? (plain var) |
206 |
The banner kind the chat list showed this app session; bannerSlotFree(banner) tells whether a kind may take the slot (see chat-list.md) |
notificationPreviewMode |
MutableState<NotificationPreviewMode> |
147 |
Notification content preview level |
showAuthScreen |
MutableState<Boolean> |
156 |
Whether to show authentication screen |
showChatPreviews |
MutableState<Boolean> |
158 |
Whether to show chat preview text in list |
networkInfo |
MutableState<UserNetworkInfo> |
186 |
Network type and online status |
conditions |
MutableState<ServerOperatorConditionsDetail> |
188 |
Server operator terms/conditions |
updatingProgress |
MutableState<Float?> |
190 |
Progress indicator for app updates |
simplexLinkMode |
MutableState<SimplexLinkMode> |
183 |
How SimpleX links are displayed |
migrationState |
MutableState<MigrationToState?> |
174 |
Database migration to new device state |
showingInvitation |
MutableState<ShowingInvitation?> |
172 |
Currently displayed invitation |
desktopOnboardingRandomPassword |
MutableState<Boolean> |
134 |
Desktop: user skipped password setup |
filesToDelete |
MutableSet<File> |
182 |
Temporary files pending cleanup |
3. ChatsContext
Defined as inner class at ChatModel.kt line 339:
ChatsContext holds the chat list and current chat items for a given context. The ChatModel maintains a primary context (chatsContext at line 107) and an optional secondary context (secondaryChatsContext at line 108).
The secondary context is used for:
- Group support chat scope (
SecondaryContextFilter.GroupChatScopeContext) -- viewing member support threads alongside the main group chat
- Message content tag filtering (
SecondaryContextFilter.MsgContentTagContext) -- filtering messages by content type
Fields
| Field |
Type |
Line |
Purpose |
secondaryContextFilter |
SecondaryContextFilter? |
339 |
Filter type: null = primary, GroupChatScope or MsgContentTag |
chats |
MutableState<SnapshotStateList<Chat>> |
340 |
List of all chats in this context |
chatItems |
MutableState<SnapshotStateList<ChatItem>> |
345 |
Items for the currently open chat in this context |
chatState |
ActiveChatState |
347 |
Tracks unread counts, splits, scroll state |
Derived Properties
| Property |
Line |
Purpose |
contentTag |
353 |
MsgContentTag? -- content filter tag if context is MsgContentTag |
groupScopeInfo |
360 |
GroupChatScopeInfo? -- group scope if context is GroupChatScope |
isUserSupportChat |
367 |
True when viewing own support chat (no specific member) |
Key Operations
addChat(chat) -- adds chat at index 0, triggers pop animation
reorderChat(chat, toIndex) -- reorders chat list (e.g., when a chat receives a new message)
updateChatInfo(rhId, cInfo) -- updates chat metadata while preserving connection stats
hasChat(rhId, id) / getChat(id) -- lookup methods
ActiveChatState
Defined at ChatItemsMerger.kt line 196:
This tracks the scroll position and unread item accounting for the lazy-loaded chat item list:
| Field |
Purpose |
splits |
List of item IDs where pagination gaps exist (items not yet loaded) |
unreadAfterItemId |
The item ID that marks the boundary of "read" vs "unread after" |
totalAfter |
Total items after the unread boundary |
unreadTotal |
Total unread items in the chat |
unreadAfter |
Unread items after the boundary (exclusive) |
unreadAfterNewestLoaded |
Unread items after the newest loaded batch |
4. Chat
Defined at ChatModel.kt line 1328:
Fields
| Field |
Type |
Purpose |
remoteHostId |
Long? |
Remote host ID (null = local) |
chatInfo |
ChatInfo |
Sealed class: Direct, Group, Local, ContactRequest, ContactConnection, InvalidJSON |
chatItems |
List<ChatItem> |
Latest chat items (summary; full list is in ChatsContext.chatItems) |
chatStats |
ChatStats |
Unread counts and stats |
ChatStats
Defined at ChatModel.kt line 1370:
Derived Properties
| Property |
Line |
Purpose |
id |
1349 |
Chat ID derived from chatInfo.id |
unreadTag |
1343 |
Whether chat counts as "unread" for tag filtering (considers notification settings) |
supportUnreadCount |
1351 |
Unread count in support/moderation context |
nextSendGrpInv |
1337 |
Whether next message should send group invitation |
ChatInfo Variants
ChatInfo is a sealed class at ChatModel.kt line 1391:
| Variant |
SerialName |
Key Data |
ChatInfo.Direct |
"direct" |
contact: Contact |
ChatInfo.Group |
"group" |
groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo? |
ChatInfo.Local |
"local" |
noteFolder: NoteFolder |
ChatInfo.ContactRequest |
"contactRequest" |
contactRequest: UserContactRequest |
ChatInfo.ContactConnection |
"contactConnection" |
contactConnection: PendingContactConnection |
ChatInfo.InvalidJSON |
"invalidJSON" |
json: String |
ChatInfo.activeChatLayout(user) is computed in the app, skipping ChatLayoutMode.Unknown values:
defaultChatLayout(user) is the same without the chat choice. Note folders have no chat values, so the profile and app choices apply. ChatView reads chatModel.currentUser, and profileChatLayout reads appPrefs.chatLayout.state, so a change of either recomposes the open chat; the chat list is not reloaded. The chat choice is written with apiSetChatUIThemes (setChatLayout, ChatInfoView.kt), the profile choice with apiSetUserUIThemes (ChatLayoutRow, Appearance.kt), together with the colours of the same record.
RelayStatus (Channels)
RelayStatus is an enum class at ChatModel.kt line 2288 modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via @SerialName.
| Case |
SerialName |
Meaning |
RsNew |
"new" |
Allocated locally; not yet sent |
RsInvited |
"invited" |
XGrpRelayInv sent, awaiting XGrpRelayAcpt |
RsAccepted |
"accepted" |
Accepted, link-data update pending |
RsActive |
"active" |
Listed in channel link data; forwarding |
RsInactive |
"inactive" |
No longer in link data or backend reports it removed |
RsRejected |
"rejected" |
Relay sent XGrpRelayReject for the channel link; final on the owner side. Clearable only by the relay operator running /group allow #<channel>. The owner-side GroupMember.memberStatus is also set to MemLeft so the relay renders identically to one that explicitly left (MemRejected is reserved for the knocking-admission flow). |
The text extension on the enum returns the localized status string (resource key relay_status_*, with relay_status_rejected = "rejected").
5. AppPreferences
Defined at SimpleXAPI.kt line 94 as class AppPreferences.
Uses the multiplatform-settings library (com.russhwolf.settings.Settings) for cross-platform key-value storage (Android SharedPreferences / Desktop java.util.prefs.Preferences).
The AppPreferences instance is created lazily in ChatController at SimpleXAPI.kt line 496:
Preference Categories
Notifications (lines 96-103)
| Key |
Type |
Default |
Purpose |
notificationsMode |
NotificationsMode |
SERVICE (if previously enabled) |
OFF / SERVICE / PERIODIC |
notificationPreviewMode |
String |
"message" |
message / contact / hidden |
canAskToEnableNotifications |
Boolean |
true |
Whether to show notification enable prompt |
backgroundServiceNoticeShown |
Boolean |
false |
Background service notice already shown |
backgroundServiceBatteryNoticeShown |
Boolean |
false |
Battery notice already shown |
autoRestartWorkerVersion |
Int |
0 |
Worker version for periodic restart |
Calls (lines 105-111)
| Key |
Type |
Default |
Purpose |
webrtcPolicyRelay |
Boolean |
true |
Use TURN relay for WebRTC |
callOnLockScreen |
CallOnLockScreen |
SHOW |
DISABLE / SHOW / ACCEPT |
webrtcIceServers |
String? |
null |
Custom ICE servers |
experimentalCalls |
Boolean |
false |
Enable experimental call features |
Authentication (lines 107-110)
| Key |
Type |
Default |
Purpose |
performLA |
Boolean |
false |
Enable local authentication |
laMode |
LAMode |
default |
Authentication mode |
laLockDelay |
Int |
30 |
Seconds before re-auth required |
laNoticeShown |
Boolean |
false |
LA notice shown |
Privacy (lines 112-128)
| Key |
Type |
Default |
Purpose |
privacyProtectScreen |
Boolean |
true |
FLAG_SECURE on Android |
privacyAcceptImages |
Boolean |
true |
Auto-accept images |
privacyLinkPreviews |
Boolean |
true |
Generate link previews |
privacySanitizeLinks |
Boolean |
false |
Remove tracking params from links |
simplexLinkMode |
SimplexLinkMode |
DESCRIPTION |
DESCRIPTION / FULL / BROWSER |
privacyShowChatPreviews |
Boolean |
true |
Show chat previews in list |
privacySaveLastDraft |
Boolean |
true |
Save compose draft |
privacyDeliveryReceiptsSet |
Boolean |
false |
Delivery receipts configured |
privacyEncryptLocalFiles |
Boolean |
true |
Encrypt local files |
privacyAskToApproveRelays |
Boolean |
true |
Ask before using relays |
privacyMediaBlurRadius |
Int |
0 |
Blur radius for media |
Network (lines 140-175)
| Key |
Type |
Default |
Purpose |
networkUseSocksProxy |
Boolean |
false |
Enable SOCKS proxy |
networkProxy |
NetworkProxy |
localhost:9050 |
Proxy host/port |
networkSessionMode |
TransportSessionMode |
default |
Session mode |
networkSMPProxyMode |
SMPProxyMode |
default |
SMP proxy mode |
networkSMPProxyFallback |
SMPProxyFallback |
default |
Proxy fallback policy |
networkHostMode |
HostMode |
default |
Host mode (onion routing) |
networkRequiredHostMode |
Boolean |
false |
Enforce host mode |
networkSMPWebPortServers |
SMPWebPortServers |
default |
Web port server config |
networkShowSubscriptionPercentage |
Boolean |
false |
Show subscription stats |
networkTCPConnectTimeout* |
Long |
varies |
TCP connect timeouts (background/interactive) |
networkTCPTimeout* |
Long |
varies |
TCP operation timeouts |
networkTCPTimeoutPerKb |
Long |
varies |
Per-KB timeout |
networkRcvConcurrency |
Int |
default |
Receive concurrency |
networkSMPPingInterval |
Long |
default |
SMP ping interval |
networkSMPPingCount |
Int |
default |
SMP ping count |
networkEnableKeepAlive |
Boolean |
default |
TCP keep-alive |
networkTCPKeepIdle |
Int |
default |
Keep-alive idle time |
networkTCPKeepIntvl |
Int |
default |
Keep-alive interval |
networkTCPKeepCnt |
Int |
default |
Keep-alive count |
Appearance (lines 213-233)
| Key |
Type |
Default |
Purpose |
currentTheme |
String |
"SYSTEM" |
Active theme name |
systemDarkTheme |
String |
"SIMPLEX" |
Theme for system dark mode |
currentThemeIds |
Map<String, String> |
empty |
Theme ID per base theme |
themeOverrides |
List<ThemeOverrides> |
empty |
Custom theme overrides |
profileImageCornerRadius |
Float |
22.5f |
Avatar corner radius |
chatItemRoundness |
Float |
0.75f |
Message bubble roundness |
chatItemTail |
Boolean |
true |
Show bubble tail |
fontScale |
Float |
1f |
Font scale factor |
densityScale |
Float |
1f |
UI density scale |
inAppBarsAlpha |
Float |
varies |
Bar transparency |
appearanceBarsBlurRadius |
Int |
50 or 0 |
Bar blur radius (device-dependent) |
Developer (lines 135-139)
| Key |
Type |
Default |
Purpose |
developerTools |
Boolean |
false |
Enable developer tools |
logLevel |
LogLevel |
WARNING |
Log level |
showInternalErrors |
Boolean |
false |
Show internal errors to user |
showSlowApiCalls |
Boolean |
false |
Alert on slow API calls |
terminalAlwaysVisible |
Boolean |
false |
Floating terminal window (desktop) |
Database (lines 188-208)
| Key |
Type |
Default |
Purpose |
onboardingStage |
OnboardingStage |
OnboardingComplete |
Current onboarding step |
storeDBPassphrase |
Boolean |
true |
Store DB passphrase in keystore |
initialRandomDBPassphrase |
Boolean |
false |
DB was created with random passphrase |
encryptedDBPassphrase |
String? |
null |
Encrypted DB passphrase |
confirmDBUpgrades |
Boolean |
false |
Confirm DB migrations |
chatStopped |
Boolean |
false |
Chat was explicitly stopped |
chatLastStart |
Instant? |
null |
Last chat start timestamp |
newDatabaseInitialized |
Boolean |
false |
DB successfully initialized at least once |
shouldImportAppSettings |
Boolean |
false |
Import settings after DB import |
selfDestruct |
Boolean |
false |
Self-destruct enabled |
selfDestructDisplayName |
String? |
null |
Display name for self-destruct profile |
UI Preferences (lines 255-257)
| Key |
Type |
Default |
Purpose |
oneHandUI |
Boolean |
true |
One-hand mode |
chatBottomBar |
Boolean |
true |
Bottom bar in chat |
chatLayout |
ChatLayoutMode |
Classic |
App chat layout (activeChatLayout) |
Remote Access (lines 238-243)
| Key |
Type |
Default |
Purpose |
deviceNameForRemoteAccess |
String |
device model |
Device name shown to paired devices |
confirmRemoteSessions |
Boolean |
false |
Confirm remote sessions |
connectRemoteViaMulticast |
Boolean |
false |
Use multicast for discovery |
connectRemoteViaMulticastAuto |
Boolean |
true |
Auto-connect via multicast |
offerRemoteMulticast |
Boolean |
true |
Offer multicast connection |
Migration (lines 189-190)
| Key |
Type |
Default |
Purpose |
migrationToStage |
String? |
null |
Migration-to-device progress |
migrationFromStage |
String? |
null |
Migration-from-device progress |
Updates and Versioning (lines 184-186, 235-237)
| Key |
Type |
Default |
Purpose |
appUpdateChannel |
AppUpdatesChannel |
DISABLED |
DISABLED / STABLE / BETA |
appSkippedUpdate |
String |
"" |
Skipped update version |
appUpdateNoticeShown |
Boolean |
false |
Update notice shown |
whatsNewVersion |
String? |
null |
Last "What's New" version seen |
lastMigratedVersionCode |
Int |
0 |
Last app version code for data migrations |
customDisappearingMessageTime |
Int |
300 |
Custom disappearing message time (seconds) |
Preference Utility Types
The SharedPreference<T> wrapper (defined in SimpleXAPI.kt) provides:
get(): T -- read current value
set(value: T) -- write value
state: MutableState<T> -- Compose-observable state (derived lazily)
Factory methods: mkBoolPreference, mkIntPreference, mkLongPreference, mkFloatPreference, mkStrPreference, mkEnumPreference, mkSafeEnumPreference, mkDatePreference, mkMapPreference, mkTimeoutPreference.
6. Source Files
| File |
Path |
Key Contents |
| ChatModel.kt |
common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt |
ChatModel singleton (line 86), ChatsContext (line 339), Chat (line 1328), ChatInfo (line 1391), ChatStats (line 1370), helper methods |
| SimpleXAPI.kt |
common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt |
AppPreferences (line 94), ChatController (line 493), startReceiver (line 660), sendCmd (line 804), recvMsg (line 829), processReceivedMsg (line 2568) |
| ChatItemsMerger.kt |
common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt |
ActiveChatState (line 196), chat item merge/diff logic |
| Core.kt |
common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt |
initChatController (line 62), state initialization flow |
| App.kt |
common/src/commonMain/kotlin/chat/simplex/common/App.kt |
AppScreen (line 47), MainScreen (line 84), top-level UI state reads |