27 KiB
System Architecture
Table of Contents
- Overview
- Module Structure
- JNI Bridge
- App Lifecycle
- Event Streaming
- Platform Abstraction
- Source Files
1. Overview
The application is a three-layer system:
+------------------------------------------------------------------+
| Compose UI (Views) |
| ChatListView, ChatView, ComposeView, SettingsView, CallView |
+------------------------------------------------------------------+
| ^
| user actions | Compose MutableState recomposition
v |
+------------------------------------------------------------------+
| Application Logic Layer |
| ChatModel (state) ChatController (command dispatch) |
| AppPreferences NtfManager ThemeManager |
+------------------------------------------------------------------+
| ^
| sendCmd() | recvMsg() / processReceivedMsg()
v |
+------------------------------------------------------------------+
| JNI Bridge (Core.kt) |
| external fun chatSendCmdRetry() external fun chatRecvMsgWait()|
+------------------------------------------------------------------+
| ^
| C FFI | C FFI
v |
+------------------------------------------------------------------+
| Haskell Core (libsimplex / libapp-lib) |
| chat_ctrl handle SMP/XFTP protocols SQLite/PostgreSQL |
+------------------------------------------------------------------+
Data flow summary:
- User interacts with Compose UI.
- View calls a
suspend fun api*()method onChatController. ChatController.sendCmd()serializes the command to a JSON string and callschatSendCmdRetry()(JNI).- The Haskell core processes the command and returns a JSON response string.
- The response is deserialized to an
APIsealed class and returned to the caller. - Asynchronous events from the core (incoming messages, connection updates, call invitations) are delivered via a receiver coroutine that calls
chatRecvMsgWait()in a loop and dispatches each event throughprocessReceivedMsg().
2. Module Structure
Gradle Configuration
Root: settings.gradle.kts
include(":android", ":desktop", ":common")
:common Module
Build file: common/build.gradle.kts
kotlin {
androidTarget()
jvm("desktop")
}
Source sets:
| Source Set | Path | Purpose |
|---|---|---|
commonMain |
common/src/commonMain/kotlin/ |
All shared UI, models, platform abstractions |
androidMain |
common/src/androidMain/kotlin/ |
Android actual implementations |
desktopMain |
common/src/desktopMain/kotlin/ |
Desktop actual implementations |
Key dependencies (from commonMain):
kotlinx-serialization-json-- JSON codec for Haskell core communicationkotlinx-datetime-- cross-platform date/timemultiplatform-settings(russhwolf) --SharedPreferencesabstractionkaml-- YAML parsing (theme import/export)boofcv-core-- QR code scanningjsoup-- HTML parsing for link previewsmoko-resources-- cross-platform string/image resourcesmultiplatform-markdown-renderer-- Markdown rendering in chat
:android Module
Build file: android/build.gradle.kts
Contains:
SimplexApp(Application subclass)MainActivity(FragmentActivity)SimplexService(foreground Service)NtfManager(Android NotificationManager wrapper)CallActivity(dedicated activity for calls)
:desktop Module
Build file: desktop/build.gradle.kts
Contains:
main()entry pointinitHaskell()-- loads native library and callsinitHS()- Window management (VLC library loading on Windows)
3. JNI Bridge
All JNI declarations reside in Core.kt.
External Native Functions
| # | Function | Signature | Line | Purpose |
|---|---|---|---|---|
| 1 | initHS() |
external fun initHS() |
18 | Initialize GHC runtime system |
| 2 | pipeStdOutToSocket() |
external fun pipeStdOutToSocket(socketName: String): Int |
20 | Redirect Haskell stdout to Android local socket for logging |
| 3 | chatMigrateInit() |
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any> |
25 | Initialize database with migration; returns [jsonResult, chatCtrl] |
| 4 | chatCloseStore() |
external fun chatCloseStore(ctrl: ChatCtrl): String |
26 | Close database store |
| 5 | chatSendCmdRetry() |
external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String |
27 | Send command to core with retry count |
| 6 | chatSendRemoteCmdRetry() |
external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String |
28 | Send command to remote host |
| 7 | chatRecvMsg() |
external fun chatRecvMsg(ctrl: ChatCtrl): String |
29 | Receive message (non-blocking) |
| 8 | chatRecvMsgWait() |
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String |
30 | Receive message with timeout (blocking up to timeout microseconds) |
| 9 | chatParseMarkdown() |
external fun chatParseMarkdown(str: String): String |
31 | Parse markdown formatting |
| 10 | chatParseServer() |
external fun chatParseServer(str: String): String |
32 | Parse SMP/XFTP server address |
| 11 | chatParseUri() |
external fun chatParseUri(str: String, safe: Int): String |
33 | Parse SimpleX connection URI |
| 12 | chatPasswordHash() |
external fun chatPasswordHash(pwd: String, salt: String): String |
34 | Hash password with salt |
| 13 | chatValidName() |
external fun chatValidName(name: String): String |
35 | Validate/sanitize display name |
| 14 | chatJsonLength() |
external fun chatJsonLength(str: String): Int |
36 | Get JSON-encoded string length |
| 15 | chatWriteFile() |
external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String |
37 | Write encrypted file via core |
| 16 | chatReadFile() |
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any> |
38 | Read and decrypt file |
| 17 | chatEncryptFile() |
external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String |
39 | Encrypt file on disk |
| 18 | chatDecryptFile() |
external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String |
40 | Decrypt file on disk |
Total: 18 external native functions (the ChatCtrl type alias at line 23 is Long, representing the Haskell-side controller pointer).
Key Kotlin Functions in Core.kt
| Function | Line | Purpose |
|---|---|---|
initChatControllerOnStart() |
51 | Entry point called during app startup; launches initChatController in a long-running coroutine |
initChatController() |
62 | Main initialization: DB migration via chatMigrateInit, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat |
chatInitTemporaryDatabase() |
190 | Creates a temporary database for migration scenarios |
chatInitControllerRemovingDatabases() |
202 | Removes existing DBs and creates fresh controller (used during re-initialization) |
showStartChatAfterRestartAlert() |
222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored |
initChatController Flow
initChatController(useKey, confirmMigrations, startChat)
|
+-- chatMigrateInit(dbPath, dbKey, confirm) // JNI -> Haskell
| returns [jsonResult, chatCtrl]
|
+-- if migration error and rerunnable:
| chatMigrateInit(dbPath, dbKey, confirm) // retry with user confirmation
|
+-- setChatCtrl(ctrl) // store controller handle
+-- apiSetAppFilePaths(...) // tell core about file dirs
+-- apiSetEncryptLocalFiles(...)
+-- apiGetActiveUser() -> currentUser
+-- getServerOperators() -> conditions
+-- if shouldImportAppSettings: apiGetAppSettings + importIntoApp
+-- if user exists and startChat confirmed:
| startChat(user) // starts receiver, API commands
+-- else if no user:
set onboarding stage, optionally startChatWithoutUser()
4. App Lifecycle
Android
Entry: SimplexApp.onCreate()
SimplexApp.onCreate()
+-- initHaskell(packageName) // Load native lib, pipe stdout, call initHS()
| +-- System.loadLibrary("app-lib")
| +-- pipeStdOutToSocket(packageName)
| +-- initHS()
+-- initMultiplatform() // Set up ntfManager, platform callbacks
+-- reconfigureBroadcastReceivers()
+-- runMigrations() // Theme migration, version code tracking
+-- initChatControllerOnStart() // -> initChatController() -> chatMigrateInit -> startChat
Activity: MainActivity.onCreate()
MainActivity.onCreate()
+-- processNotificationIntent(intent) // Handle OpenChat/AcceptCall from notifications
+-- processIntent(intent) // Handle VIEW intents (deep links)
+-- processExternalIntent(intent) // Handle SEND/SEND_MULTIPLE (share sheet)
+-- setContent { AppScreen() } // Compose UI entry point
Lifecycle callbacks in SimplexApp (implements LifecycleEventObserver):
ON_START: refresh chat list from API if chat is runningON_RESUME: show background service notice, startSimplexServiceif configured
Desktop
Entry: main()
main()
+-- initHaskell() // Load native lib from resources dir, call initHS()
| +-- System.load(libapp-lib.so/dll/dylib)
| +-- initHS()
+-- runMigrations()
+-- setupUpdateChecker()
+-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart
+-- showApp() // Compose window with AppScreen()
showApp() creates a Compose Window with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window.
initApp() sets the ntfManager implementation (desktop notifications via NtfManager in common/model/) and calls initChatControllerOnStart().
5. Event Streaming
Receiver Coroutine
ChatController.startReceiver() launches a coroutine on Dispatchers.IO that continuously polls for events from the Haskell core:
// SimpleXAPI.kt line 660
private fun startReceiver() {
if (receiverJob != null || chatCtrl == null) return // guard against double-start
receiverJob = CoroutineScope(Dispatchers.IO).launch {
var releaseLock: (() -> Unit) = {}
while (isActive) {
val ctrl = chatCtrl
if (ctrl == null) { stopReceiver(); break } // chatCtrl became null
try {
val release = releaseLock
launch { delay(30000); release() } // release previous wake lock after 30s
val msg = recvMsg(ctrl) // calls chatRecvMsgWait with 300s timeout
releaseLock = getWakeLock(timeout = 60000) // acquire wake lock (60s timeout)
if (msg != null) {
val finished = withTimeoutOrNull(60_000L) {
processReceivedMsg(msg)
messagesChannel.trySend(msg)
}
if (finished == null) {
Log.e(TAG, "Timeout processing: " + msg.responseType)
}
}
} catch (e: Exception) {
Log.e(TAG, "recvMsg/processReceivedMsg exception: " + e.stackTraceToString())
} catch (e: Throwable) {
Log.e(TAG, "recvMsg/processReceivedMsg throwable: " + e.stackTraceToString())
}
}
}
}
Message Reception
recvMsg() calls chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) where MESSAGE_TIMEOUT = 300_000_000 microseconds (300 seconds). Returns null on timeout (empty string from Haskell), otherwise deserializes the JSON response to an API instance.
Command Sending
sendCmd() runs on Dispatchers.IO, serializes the command via CC.cmdString, calls chatSendCmdRetry() (or chatSendRemoteCmdRetry() for remote hosts), deserializes the response, and logs terminal items.
Event Processing
processReceivedMsg() is a large when block that dispatches on the CR (ChatResponse) type:
CR.ContactConnected-- update contact inChatModelCR.NewChatItems-- add items to chat, trigger notificationsCR.RcvCallInvitation-- add tocallInvitations, trigger call UICR.ChatStopped-- setchatRunning = falseCR.GroupMemberConnected,CR.GroupMemberUpdated, etc. -- update group state- Many more event types for connection status, file transfers, SMP relay events, etc.
Wake Lock
On Android, the receiver acquires a wake lock via getWakeLock(timeout) (expect function) after each received message with a 60-second timeout. The previous iteration's wake lock is released after a 30-second delay, ensuring overlap so the CPU does not sleep between messages.
6. Platform Abstraction
expect/actual Pattern
The commonMain source set declares expect functions and classes. Each platform source set provides actual implementations.
Examples from platform files:
| expect Declaration | File | Line |
|---|---|---|
expect val appPlatform: AppPlatform |
AppCommon.kt |
20 |
expect val deviceName: String |
AppCommon.kt |
22 |
expect fun isAppVisibleAndFocused(): Boolean |
AppCommon.kt |
24 |
expect val dataDir: File |
Files.kt |
18 |
expect val tmpDir: File |
Files.kt |
19 |
expect val filesDir: File |
Files.kt |
20 |
expect val appFilesDir: File |
Files.kt |
21 |
expect val dbAbsolutePrefixPath: String |
Files.kt |
24 |
expect fun showToast(text: String, timeout: Long) |
UI.kt |
6 |
expect fun hideKeyboard(view: Any?, clearFocus: Boolean) |
UI.kt |
16 |
expect fun getKeyboardState(): State<KeyboardState> |
UI.kt |
15 |
expect fun allowedToShowNotification(): Boolean |
Notifications.kt |
3 |
expect class VideoPlayer |
VideoPlayer.kt |
25 |
expect class RecorderNative |
RecAndPlay.kt |
17 |
expect val cryptor: CryptorInterface |
Cryptor.kt |
9 |
expect fun base64ToBitmap(base64ImageString: String): ImageBitmap |
Images.kt |
17 |
expect fun getWakeLock(timeout: Long): (() -> Unit) |
SimplexService.kt |
3 |
expect class GlobalExceptionsHandler |
UI.kt |
24 |
expect fun UriHandler.sendEmail(subject: String, body: CharSequence) |
Share.kt |
7 |
expect fun ClipboardManager.shareText(text: String) |
Share.kt |
9 |
expect fun shareFile(text: String, fileSource: CryptoFile) |
Share.kt |
10 |
PlatformInterface Callback Object
PlatformInterface is an interface with default no-op implementations. It is assigned at runtime by each platform entry point:
- Android: assigned in
SimplexApp.initMultiplatform()(line 187) - Desktop: assigned in
Main.kt initHaskell()(line 50)
The global variable is declared at Platform.kt line 50:
var platform: PlatformInterface = object : PlatformInterface {}
PlatformInterface Callbacks
| Callback | Default | Android Implementation |
|---|---|---|
androidServiceStart() |
no-op | Start SimplexService foreground service |
androidServiceSafeStop() |
no-op | Stop SimplexService |
androidCallServiceSafeStop() |
no-op | Stop CallService |
androidNotificationsModeChanged(mode) |
no-op | Toggle receivers, start/stop service |
androidChatStartedAfterBeingOff() |
no-op | Start service or schedule periodic worker |
androidChatStopped() |
no-op | Cancel workers, stop service |
androidChatInitializedAndStarted() |
no-op | Show background service notice, start service |
androidIsBackgroundCallAllowed() |
true |
Check battery restriction |
androidSetNightModeIfSupported() |
no-op | Set UiModeManager night mode |
androidSetStatusAndNavigationBarAppearance(...) |
no-op | Configure system bar colors/appearance |
androidStartCallActivity(acceptCall, rhId, chatId) |
no-op | Launch CallActivity |
androidPictureInPictureAllowed() |
true |
Check PiP permission via AppOps |
androidCallEnded() |
no-op | Destroy call WebView |
androidRestartNetworkObserver() |
no-op | Restart NetworkObserver |
androidCreateActiveCallState() |
empty Closeable |
Create ActiveCallState |
androidIsXiaomiDevice() |
false |
Check device brand |
androidApiLevel |
null |
Build.VERSION.SDK_INT |
androidLockPortraitOrientation() |
no-op | Lock to SCREEN_ORIENTATION_PORTRAIT |
androidAskToAllowBackgroundCalls() |
true |
Show battery restriction dialog |
desktopShowAppUpdateNotice() |
no-op | Show update notice (Desktop only) |
7. Source Files
Core Infrastructure
| File | Path | Key Contents |
|---|---|---|
| Core.kt | common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt |
JNI externals, initChatController, chatInitTemporaryDatabase |
| SimpleXAPI.kt | common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt |
ChatController, AppPreferences, startReceiver, sendCmd, recvMsg, processReceivedMsg, all api* functions |
| ChatModel.kt | common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt |
ChatModel singleton, ChatsContext, Chat, ChatInfo, ChatItem and all domain types |
| App.kt | common/src/commonMain/kotlin/chat/simplex/common/App.kt |
AppScreen(), MainScreen() |
Platform Layer
Entry Points
| File | Path | Key Contents |
|---|---|---|
| SimplexApp.kt | android/src/main/java/chat/simplex/app/SimplexApp.kt |
Android Application class, lifecycle observer |
| MainActivity.kt | android/src/main/java/chat/simplex/app/MainActivity.kt |
Android main activity |
| SimplexService.kt | android/src/main/java/chat/simplex/app/SimplexService.kt |
Android foreground service |
| Main.kt | desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt |
Desktop main() |
| DesktopApp.kt | common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt |
showApp(), SimplexWindowState |
Theme
| File | Path | Key Contents |
|---|---|---|
| ThemeManager.kt | common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt |
Theme resolution, system/light/dark/custom, per-user overrides |