mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 17:17:39 +00:00
* WIP: Haskell impl of resizeImageToStrSize * WIP: add FFI export wrapper * WIP: switch ios resizeImageToStrSizeSync to backend * WIP: fix resizeImageToStrSizeSync * WIP: add JNI wrappers * Add non-base64 resize and cWriteImage * WIP: add chat_write_image * fix resizeToStr * fixes * fix syntax, raise jpeg quality back * add chatWriteImage * Fix cfArgs in tests * Use jpeg-turbo encoder * add nix pins * bump jpeg-turbo * add CI deps * add encoding funs to android lib * bump jpeg-turbo for 20.04 fix * jpeg-turbo fix for 8.10 * android fixes * Bump jpeg-turbo * set static flag for jpeg-turbo * Fix nix for aarch64/android * Fix android C api * Fix resize in android * Bump hs-libjpeg-turbo * target correct override * override android lib from android pkgs * add macos build tweaks * bump hs-jpeg-turbo for 8.10 fixes * more build fixes * make ios compile * Always use pkgconfig for libjpeg-turbo * WIP: debug libjpeg in ubuntu pkg-config CI * Add pkg-config to dockerfiles * Add libturbojpeg to dockerfiles * Use -dev version of libturbojpeg in dockerfiles * Install pkg-config in windows CI * Disable flag for custom libturbojpeg linking options Workarounds relocation error on x86_64-linux with gcc: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/libturbojpeg.a(turbojpeg.c.o): relocation R_X86_64_TPOFF32 against `errStr' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: failed to set dynamic section sizes: bad value * WIP: debug libjpeg in windows pkg-config CI * WIP: debug libjpeg package in windows CI * core: image resize module and buffer FFI * ci: link libjpeg-turbo statically on all platforms * ui: resize images in the core * core: rename image resize functions to chat_resize_image and chat_resize_image_uri * ci: restore whitespace in build.yml * ci: build libjpeg-turbo 3.1.4.1 from a submodule * core: hs-jpeg-turbo from github.com/simplex-chat * nodejs: resize images * python: resize images * core: hs-jpeg-turbo from gitlab.com, the GitHub import lacks LFS objects * core: generate /img previews * ci: link libjpeg-turbo on Windows without pkg-config * ci: build libjpeg-turbo on Windows with the GHC toolchain * docs: plan for image resizing in the core * docs: update image resizing plan * ios: prevent crash when wallpaper image cannot be resized * android, desktop: raise an error when an image cannot be resized * docs: update spec references for image resizing * docker: build the CLI image from a clone without submodules * docs: apply review fixes to image resizing plan * docker: build the CLI from a git worktree with the submodule present * docs: apply second review to image resizing plan * docs: point image resizing plan at the rewritten commit * core: choose image format by transparency in chat_resize_image * nodejs: resize images to the app sizes * python: resize images to the app sizes * nodejs, python: describe the core modules as used for image resizing * docs: add license notices of the image libraries * ci: describe the avatar method without attributing it to the apps * ci, ios, nix: remove the libjpeg-turbo build outside cabal * python: name resize_image in the core module text * nodejs, python: describe resizing to the app image sizes * core: refuse images larger than the supported size * core: resize to JPEG images with a side above 65500 pixels * docs: record image resizing decisions for the bundled libjpeg-turbo * core, docs: build libjpeg-turbo and stb from the fork submodules * docs: record the fork pins in the image resizing plan * core: resize images to at most 4320 pixels per side, refuse above 16384 * docs: record the image side limits in the image resizing plan * core: pin the rebased hs-jpeg-turbo fork * core: pin the hs-jpeg-turbo fork without package.yaml * docs: plan scaled JPEG decoding and EXIF orientation of images * docs: copy the JuicyPixels-stbir licence of the pinned fork * core: pin the hs-jpeg-turbo fork with scaled JPEG decoding * core: decode JPEG at the display size, up to 65500 pixels per side * core: rotate JPEG images by their EXIF orientation * docs: refer the image plan to the JPEG decoding code * docs: record the approved image processing decisions * core: limit JPEG decoding memory to 1024 MB outside iOS and Android * docs: name JPEG decoding in the libjpeg-turbo notice * docs: plan image resizing from files and the decisions of 26 Oct * core: scale images in one Catmull-Rom pass with sharpening * docs: plan image sizes at a fixed quality * core: resize images at a fixed quality to fill the size limit * core: resize images from a file path or a file descriptor * ios, android, desktop: resize picked images from their files * core: encode resized images with mozjpeg * tests: check descriptor closing by the file it refers to * core: raise JPEG quality only for a full-size image below 97% of the limit * ci: name the mozjpeg submodule in the Windows cleanup comments * docs: refer the image plan to the current code * docs: refer the image plan to the code merged with master * core: export every image resizing function from each library * core: close the file descriptor when its handle cannot be created * docs: count the C sources of mozjpeg with the arithmetic decoder * core: use the reviewed hs-jpeg-turbo and JuicyPixels-stbir forks * ci: build the armv7a Android core library on request * core: round image heights half up in the size search * ios: describe the file image resizing functions in SimpleX.h * ci: pick, preview and send images in the Claude app tests * core: decode PNG images with libspng and refuse other formats * core: encode PNG with libspng, keeping the smaller filter choice * ci: pick, preview and send images in the scripted app smoke tests * flake: build the PNG decoder package in nix builds * ios: remove the copy of a picked image after sending it * core: refuse image files larger than the decode memory limit * core: scale a JPEG image to its largest size once per resize * core: bisect JPEG quality without a step function * core: reduce the memory used to sharpen and to decode PNG images * android, desktop: pass the image format to the core as an integer * android, desktop: fuse the preview conditions of picked media * android, desktop, ci: check that the core resized the smoke test images * python: remove the docstrings of the explicit image resizing functions * docs: add the second review fixes to the image plan * ci: tap the iOS attachment button by its accessibility label * core: keep the colour profiles of resized images * docs: add the colour profiles to the image plan * ci: wait up to 30 s for the new group screens in the iOS smoke test * ci: pick the Android smoke test images from their own photo folders * ci: tap the iOS photo picker by position, as its tree is not shown * ci: pick the test card and the logo by their iOS picker positions * core: update JuicyPixels-stbir with the stb_image_resize licence terms * core: write a fixed Display P3 profile for Display P3 images only * core: do not keep the input image in memory for its colour profile * core: read the JPEG segments once for the orientation and the profile * core: read an image file into one buffer of its size * docs: add the third review fixes to the image plan * core: resize images with Display P3 or no profile, not profile bytes * ci: check the pixel sizes of the images sent in the iOS smoke test * core: update hs-jpeg-turbo with PNG chunk limits and encoder fixes * docs: list Compact ICC Profiles, libspng and miniz in the dependencies * android, desktop: reword the picked media preview comment * docs: refer the image plan to the code merged with master * ci: render the iOS snapshots before the images case * ci: dismiss the snapshot tests' camera alert before the iOS images case * core: update hs-jpeg-turbo with tests and the JuicyPixels-spng synopsis * core: pin hs-jpeg-turbo and JuicyPixels-stbir in simplex-chat repositories * update ios core library * core, ui: accept only JPEG and PNG image data * core: update JuicyPixels-stbir * docs: plan for the iOS copy of picked images --------- Co-authored-by: IC Rainbow <aenor.realm@gmail.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Alexandre Esteves <alexfmpe@proton.me>
443 lines
31 KiB
Markdown
443 lines
31 KiB
Markdown
# System Architecture
|
||
|
||
## Table of Contents
|
||
|
||
1. [Overview](#1-overview)
|
||
2. [Module Structure](#2-module-structure)
|
||
3. [JNI Bridge](#3-jni-bridge)
|
||
4. [App Lifecycle](#4-app-lifecycle)
|
||
5. [Event Streaming](#5-event-streaming)
|
||
6. [Platform Abstraction](#6-platform-abstraction)
|
||
7. [Source Files](#7-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:**
|
||
1. User interacts with Compose UI.
|
||
2. View calls a `suspend fun api*()` method on `ChatController`.
|
||
3. `ChatController.sendCmd()` serializes the command to a JSON string and calls `chatSendCmdRetry()` (JNI).
|
||
4. The Haskell core processes the command and returns a JSON response string.
|
||
5. The response is deserialized to an `API` sealed class and returned to the caller.
|
||
6. 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 through `processReceivedMsg()`.
|
||
|
||
---
|
||
|
||
## 2. Module Structure
|
||
|
||
### Gradle Configuration
|
||
|
||
Root: [`settings.gradle.kts`](../settings.gradle.kts#L22)
|
||
```
|
||
include(":android", ":desktop", ":common")
|
||
```
|
||
|
||
### `:common` Module
|
||
|
||
Build file: [`common/build.gradle.kts`](../common/build.gradle.kts#L14)
|
||
|
||
```
|
||
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 communication
|
||
- `kotlinx-datetime` -- cross-platform date/time
|
||
- `multiplatform-settings` (russhwolf) -- `SharedPreferences` abstraction
|
||
- `kaml` -- YAML parsing (theme import/export)
|
||
- `boofcv-core` -- QR code scanning
|
||
- `jsoup` -- HTML parsing for link previews
|
||
- `moko-resources` -- cross-platform string/image resources
|
||
- `multiplatform-markdown-renderer` -- Markdown rendering in chat
|
||
|
||
### `:android` Module
|
||
|
||
Build file: [`android/build.gradle.kts`](../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`](../desktop/build.gradle.kts)
|
||
|
||
Contains:
|
||
- `main()` entry point
|
||
- `initHaskell()` -- loads native library and calls `initHS()`
|
||
- Window management (VLC library loading on Windows)
|
||
|
||
---
|
||
|
||
## 3. JNI Bridge
|
||
|
||
All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt).
|
||
|
||
<a id="initHS"></a>
|
||
<a id="chatMigrateInit"></a>
|
||
|
||
### External Native Functions
|
||
|
||
| # | Function | Signature | Line | Purpose |
|
||
|---|---|---|---|---|
|
||
| 1 | [`initHS()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | Initialize GHC runtime system |
|
||
| 2 | [`pipeStdOutToSocket()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L20) | `external fun pipeStdOutToSocket(socketName: String): Int` | 20 | Redirect Haskell stdout to Android local socket for logging |
|
||
| 3 | [`chatMigrateInit()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25) | `external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>` | 25 | Initialize database with migration; returns `[jsonResult, chatCtrl]` |
|
||
| 4 | [`chatCloseStore()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L26) | `external fun chatCloseStore(ctrl: ChatCtrl): String` | 26 | Close database store |
|
||
| 5 | [`chatSendCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L27) | `external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String` | 27 | Send command to core with retry count |
|
||
| 6 | [`chatSendRemoteCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L28) | `external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String` | 28 | Send command to remote host |
|
||
| 7 | [`chatRecvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L29) | `external fun chatRecvMsg(ctrl: ChatCtrl): String` | 29 | Receive message (non-blocking) |
|
||
| 8 | [`chatRecvMsgWait()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L30) | `external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String` | 30 | Receive message with timeout (blocking up to `timeout` microseconds) |
|
||
| 9 | [`chatParseMarkdown()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L31) | `external fun chatParseMarkdown(str: String): String` | 31 | Parse markdown formatting |
|
||
| 10 | [`chatParseServer()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L32) | `external fun chatParseServer(str: String): String` | 32 | Parse SMP/XFTP server address |
|
||
| 11 | [`chatParseUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L33) | `external fun chatParseUri(str: String, safe: Int): String` | 33 | Parse SimpleX connection URI |
|
||
| 12 | [`chatPasswordHash()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L34) | `external fun chatPasswordHash(pwd: String, salt: String): String` | 34 | Hash password with salt |
|
||
| 13 | [`chatValidName()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L35) | `external fun chatValidName(name: String): String` | 35 | Validate/sanitize display name |
|
||
| 14 | [`chatParseBadgeCode()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatParseBadgeCode(code: String): String` | 36 | Canonical form of a badge code, or an empty string |
|
||
| 15 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatJsonLength(str: String): Int` | 37 | Get JSON-encoded string length |
|
||
| 16 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 38 | Write encrypted file via core |
|
||
| 17 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>` | 39 | Read and decrypt file |
|
||
| 18 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 40 | Encrypt file on disk |
|
||
| 19 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L41) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 41 | Decrypt file on disk |
|
||
| 20 | [`chatResizeImageUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L42) | `external fun chatResizeImageUri(buffer: ByteBuffer, maxSize: Int): String` | 42 | Resize encoded image to a data URI of at most `maxSize` characters |
|
||
| 21 | [`chatResizeImage()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L43) | `external fun chatResizeImage(buffer: ByteBuffer, maxSize: Int, png: Int): String` | 43 | Resize encoded image to base64 of PNG or JPEG data of at most `maxSize` bytes |
|
||
| 22 | [`chatResizeImageFileUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L44) | `external fun chatResizeImageFileUri(path: String, maxSize: Int): String` | 44 | Desktop: as `chatResizeImageUri`, from the file at `path` |
|
||
| 23 | [`chatResizeImageFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L45) | `external fun chatResizeImageFile(path: String, maxSize: Int, png: Int): String` | 45 | Desktop: as `chatResizeImage`, from the file at `path`; `png` −1 selects the format by transparency |
|
||
| 24 | [`chatResizeImageFdUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L46) | `external fun chatResizeImageFdUri(fd: Int, maxSize: Int): String` | 46 | Android: as `chatResizeImageUri`, from the file descriptor `fd`, which core closes |
|
||
| 25 | [`chatResizeImageFd()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L47) | `external fun chatResizeImageFd(fd: Int, maxSize: Int, png: Int): String` | 47 | Android: as `chatResizeImage`, from the file descriptor `fd`, which core closes |
|
||
|
||
**Total: 25 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer).
|
||
|
||
<a id="initChatControllerOnStart"></a>
|
||
<a id="chatInitTemporaryDatabase"></a>
|
||
<a id="chatInitControllerRemovingDatabases"></a>
|
||
|
||
### Key Kotlin Functions in Core.kt
|
||
|
||
| Function | Line | Purpose |
|
||
|---|---|---|
|
||
| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine |
|
||
| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat |
|
||
| [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios |
|
||
| [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) |
|
||
| [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored |
|
||
|
||
<a id="initChatController"></a>
|
||
|
||
### 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()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L47)
|
||
|
||
```
|
||
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()`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L32)
|
||
|
||
```
|
||
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 running
|
||
- `ON_RESUME`: show background service notice, start `SimplexService` if configured
|
||
|
||
### Desktop
|
||
|
||
Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26)
|
||
|
||
```
|
||
main(args)
|
||
+-- linkFromArgs(args) // A simplexchat: or simplex: link passed by the OS
|
||
+-- acquireSingleInstance(link) // Or forward the link to the running instance and exit
|
||
+-- appOpenUrl = link // Stored before the watcher can forward a newer link
|
||
+-- startShowFileWatcher() // Only while holding the single-instance lock
|
||
+-- initHaskell() // Load native lib from resources dir, call initHS()
|
||
| +-- System.load(libapp-lib.so/dll/dylib)
|
||
| +-- initHS()
|
||
+-- runMigrations()
|
||
+-- setupUpdateChecker()
|
||
+-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart
|
||
+-- registerLinkSchemes() // Background thread, per-OS registration and detection
|
||
+-- installOpenUriHandler() // macOS only: links arrive as Apple Events
|
||
+-- showApp() // Compose window with AppScreen()
|
||
```
|
||
|
||
[`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window.
|
||
|
||
[`showWindow()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L258) un-minimizes and raises the window when a second launch or a link reaches the running app. On Linux it also sends `_NET_ACTIVE_WINDOW` through [`requestX11Activation()`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt#L17), because wlroots compositors such as Sway ignore the raise that AWT's `toFront()` sends under XWayland. On Windows the second process, which the browser left holding the foreground right, calls `AllowSetForegroundWindow` before it signals so that the running instance may raise its window. During a crash restart `showWindow()` skips the disposed window, so it does not come back; the new window opens visible.
|
||
|
||
[`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`.
|
||
|
||
---
|
||
|
||
## 5. Event Streaming
|
||
|
||
### Receiver Coroutine
|
||
|
||
[`ChatController.startReceiver()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660) launches a coroutine on `Dispatchers.IO` that continuously polls for events from the Haskell core:
|
||
|
||
```kotlin
|
||
// 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()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L829) 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()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804) 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()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568) is a large `when` block that dispatches on the `CR` (ChatResponse) type:
|
||
|
||
- `CR.ContactConnected` -- update contact in `ChatModel`
|
||
- `CR.NewChatItems` -- add items to chat, trigger notifications
|
||
- `CR.RcvCallInvitation` -- add to `callInvitations`, trigger call UI
|
||
- `CR.ChatStopped` -- set `chatRunning = false`
|
||
- `CR.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)`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) (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`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L20) | 20 |
|
||
| `expect val deviceName: String` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L22) | 22 |
|
||
| `expect fun isAppVisibleAndFocused(): Boolean` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L24) | 24 |
|
||
| `expect val dataDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | 18 |
|
||
| `expect val tmpDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | 19 |
|
||
| `expect val filesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | 20 |
|
||
| `expect val appFilesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | 21 |
|
||
| `expect val dbAbsolutePrefixPath: String` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | 24 |
|
||
| `expect fun showToast(text: String, timeout: Long)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L6) | 6 |
|
||
| `expect fun hideKeyboard(view: Any?, clearFocus: Boolean)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L16) | 16 |
|
||
| `expect fun getKeyboardState(): State<KeyboardState>` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L15) | 15 |
|
||
| `expect fun allowedToShowNotification(): Boolean` | [`Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt#L3) | 3 |
|
||
| `expect class VideoPlayer` | [`VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt#L25) | 25 |
|
||
| `expect class RecorderNative` | [`RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt#L17) | 17 |
|
||
| `expect val cryptor: CryptorInterface` | [`Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt#L9) | 9 |
|
||
| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L18) | 18 |
|
||
| `expect fun getWakeLock(timeout: Long): (() -> Unit)` | [`SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) | 3 |
|
||
| `expect class GlobalExceptionsHandler` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L24) | 24 |
|
||
| `expect fun UriHandler.sendEmail(subject: String, body: CharSequence)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L7) | 7 |
|
||
| `expect fun ClipboardManager.shareText(text: String)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L9) | 9 |
|
||
| `expect fun shareFile(text: String, fileSource: CryptoFile)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L10) | 10 |
|
||
|
||
### PlatformInterface Callback Object
|
||
|
||
[`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point:
|
||
|
||
- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L193)
|
||
- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L59)
|
||
|
||
The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50):
|
||
```kotlin
|
||
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` |
|
||
| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` |
|
||
| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) |
|
||
| `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`](../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`](../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`](../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`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen()`, `MainScreen()` |
|
||
|
||
### Platform Layer
|
||
|
||
| File | Path | Key Contents |
|
||
|---|---|---|
|
||
| Platform.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt) | `PlatformInterface`, global `platform` var |
|
||
| AppCommon.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt) | `AppPlatform`, `runMigrations()` |
|
||
| AppCommon.android.kt | [`common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt) | `initHaskell()`, `androidAppContext` |
|
||
| AppCommon.desktop.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt) | `initApp()`, desktop NtfManager setup |
|
||
| Files.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | `expect val dataDir/tmpDir/filesDir/dbAbsolutePrefixPath` |
|
||
| NtfManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | `abstract class NtfManager` |
|
||
| Notifications.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt) | `expect fun allowedToShowNotification()` |
|
||
| UI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt) | `showToast`, `hideKeyboard`, `GlobalExceptionsHandler` |
|
||
| Share.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt) | `shareText`, `shareFile`, `openFile` |
|
||
| VideoPlayer.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt) | `VideoPlayerInterface`, `expect class VideoPlayer` |
|
||
| RecAndPlay.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt) | `RecorderInterface`, `AudioPlayerInterface` |
|
||
| Cryptor.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt) | `CryptorInterface` |
|
||
| Images.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt) | `base64ToBitmap`, `resizeImageToStrSize` |
|
||
| SimplexService.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt) | `expect fun getWakeLock()` |
|
||
|
||
### Entry Points
|
||
|
||
| File | Path | Key Contents |
|
||
|---|---|---|
|
||
| SimplexApp.kt | [`android/src/main/java/chat/simplex/app/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/src/main/java/chat/simplex/app/MainActivity.kt) | Android main activity |
|
||
| SimplexService.kt | [`android/src/main/java/chat/simplex/app/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/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` |
|
||
| DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` |
|
||
| SingleInstance.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt) | `acquireSingleInstance()`, `startShowFileWatcher()`, signal file hand-over |
|
||
| WindowActivation.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/WindowActivation.kt) | `requestX11Activation()`, `sendActivation()`, `activationEvent()` |
|
||
|
||
### Theme
|
||
|
||
| File | Path | Key Contents |
|
||
|---|---|---|
|
||
| ThemeManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | Theme resolution, system/light/dark/custom, per-user overrides |
|