22 KiB
Known Gaps & Recommendations -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform)
This document catalogs known gaps in the multiplatform codebase (Android and Desktop) with severity, impact, and recommendations.
Table of Contents
- UI: Error Feedback
- UI: Loading States
- Security: Database Passphrase Not Enforced
- Security: No Forward Secrecy Indicator
- Documentation: Haskell Store Layer Not Fully Specified
- Desktop: Recording Not Implemented
- Desktop: Cryptor Not Implemented
- Desktop: App Link Registration
- Desktop: Emoji Picker Without Skin Tones and Localized Search
- Unread Counters Drift
- addChatItem Writes the Primary List Using the Current Context's Index
- Chat List: Silent 5000-Chat Limit
GAP-01: UI Error Feedback
Severity: Medium Category: UI / UX Platforms: Android, Desktop
Description
Many API calls through ChatController.sendCmd() return API.Error responses that are logged but not surfaced to the user. The general pattern is:
val r = sendCmd(rh, cmd)
if (r is API.Result && r.res is CR.ExpectedResponse) return r.res.value
Log.e(TAG, "someFunction bad response: ${r.responseType} ${r.details}")
return null
When the call fails, the caller receives null and either silently does nothing or shows a generic error. The specific ChatError details (which may contain actionable information like quota exceeded, server unreachable, or store errors) are lost to the user.
Affected Locations
SimpleXAPI.kt--getAgentSubsTotal(),getAgentServersSummary(), and dozens of similarapi*functions- Throughout the codebase wherever
sendCmdresults are pattern-matched
Impact
Users experience silent failures with no indication of what went wrong. This is particularly problematic for:
- Connection attempts that fail due to network issues
- File transfer failures
- Group operations that fail due to role permissions
- Server configuration errors
Recommendation
- Introduce a structured error-handling utility that maps
ChatErrorsubtypes to user-visible messages, similar to howretryableNetworkErrorAlertalready handles a subset ofAgentErrorType.BROKERerrors. - At minimum, surface a dismissible snackbar/toast with a summary when an API call fails unexpectedly.
- For critical operations (send message, join group, create connection), show a dialog with retry/cancel options (the
sendCmdWithRetrypattern already exists for some cases -- extend it).
GAP-02: UI Loading States
Severity: Low-Medium Category: UI / UX Platforms: Android, Desktop
Description
Several long-running operations lack loading indicators, leaving the user uncertain whether the action is in progress. The ComposeState.inProgress flag and progressByTimeout mechanism exist for the compose area, and ConnectProgressManager handles connection progress, but many other flows have no visual feedback.
Affected Locations
- Group member list loading (
ChatModel.membersLoadedexists but is not always checked before displaying stale data) - Server configuration validation (
ApiValidateServerscan take several seconds with no indicator) - Database export/import (
ApiExportArchive,ApiImportArchive) - Profile switching (
changeActiveUser_acquireschangingActiveUserMutexbut the UI may appear frozen)
Impact
Users may tap actions multiple times, causing duplicate requests, or assume the app is frozen and force-quit during a long operation like database export.
Recommendation
- Introduce a centralized
ProgressOverlaycomposable that can be shown/hidden via aChatModelflag. - Wrap all operations that acquire
changingActiveUserMutexor take > 1 second with a visible loading state. - Use
ChatModel.switchingUsersAndHosts(which already exists) more consistently as a gate for showing a blocking progress indicator.
GAP-03: Security: Database Passphrase Not Enforced
Severity: High Category: Security Platforms: Android, Desktop
Description
When the app is first installed, a random database passphrase is generated and stored in encrypted preferences. The user is never required to set a custom passphrase. The initialRandomDBPassphrase flag tracks this state, and a setup prompt exists in onboarding (SetupDatabasePassphrase), but the user can skip it.
On Android, the encrypted passphrase is stored via the Android Keystore, which provides hardware-backed security. On Desktop, the Cryptor is a placeholder (see GAP-07), meaning the passphrase is stored in plaintext.
Affected Locations
SimpleXAPI.kt--AppPreferences.storeDBPassphrase,AppPreferences.initialRandomDBPassphrase,AppPreferences.encryptedDBPassphrasecommon/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.ktcommon/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt
Impact
- Users who skip passphrase setup rely entirely on device security. If the device is compromised, the database can be decrypted using the stored passphrase.
- On Desktop, the passphrase is effectively stored in plaintext (see GAP-07), meaning anyone with filesystem access can read the database.
Recommendation
- Consider making passphrase setup mandatory during onboarding (or at least prominently warn users who skip it).
- On Desktop, implement proper key storage (GAP-07) before any passphrase enforcement is meaningful.
- Add a periodic reminder for users who still have
initialRandomDBPassphrase == true.
GAP-04: Security: No Forward Secrecy Indicator
Severity: Medium Category: Security / UI Platforms: Android, Desktop
Description
The double-ratchet algorithm provides forward secrecy per message, and PQ key exchange provides resistance to quantum attacks. The Connection type tracks pqSupport, pqEncryption, pqSndEnabled, and pqRcvEnabled. However, the UI does not prominently display the current forward secrecy state or PQ encryption status for a given conversation.
Affected Locations
ChatModel.kt--Connection.pqSupport,Connection.pqEncryption,Connection.pqSndEnabled,Connection.pqRcvEnabled- Contact info views, group member info views
Impact
Users cannot easily verify whether their conversations are using PQ-enhanced encryption. Security-conscious users have no visual indicator of the ratchet state or whether PQ key exchange was successful.
Recommendation
- Add a security badge/icon in the chat header or contact info screen showing:
- Whether PQ key exchange is active (both peers support it)
- Whether the connection has been verified (security code comparison)
- The ratchet state (in-sync vs. needs re-sync)
- The
connectionCodefield onConnectioncan be used to show verification status. - The
Call.encryptionStatuspattern (used in call views) could be adapted for the chat view.
GAP-05: Documentation: Haskell Store Layer Not Fully Specified
Severity: Medium Category: Documentation / Architecture Platforms: Android, Desktop
Description
The Kotlin client communicates with the Haskell core via a text-based command protocol (CC.cmdString -> FFI -> Haskell). The Haskell store layer (SQLite operations, migration logic, and the exact semantics of StoreError variants) is not documented from the Kotlin side. The ChatErrorStore error type wraps a StoreError whose variants are defined in Haskell and deserialized by the Kotlin client, but the conditions under which each error occurs are not specified.
Affected Locations
SimpleXAPI.kt:6986--ChatErrorStore(storeError: StoreError)SimpleXAPI.kt--StoreErrorsealed class (deserialized from Haskell responses)SimpleXAPI.kt--ChatErrorDatabase(databaseError: DatabaseError)for migration errors
Impact
- Developers cannot predict which
StoreErrorwill occur for a given operation without reading the Haskell source. - Error handling in the Kotlin layer is necessarily generic since the error semantics are not specified.
- Migration failures (
ChatErrorDatabase) are particularly opaque.
Recommendation
- Create a specification document mapping each
CCcommand to its possibleStoreError/DatabaseErrorresponses. - Document the database migration versioning scheme and the conditions under which
confirmDBUpgradesis triggered. - Add inline documentation to the
StoreErrorsealed class variants explaining their trigger conditions.
GAP-06: Desktop: Recording Not Implemented
Severity: High Category: Feature / Platform Platform: Desktop only
Description
The RecorderNative class on Desktop is a placeholder. Both start() and stop() are stubbed with /*LALAL*/ comments and return dummy values (empty string and 0, respectively). Users cannot record voice messages on Desktop.
// common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt
actual class RecorderNative: RecorderInterface {
override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String {
/*LALAL*/
return ""
}
override fun stop(): Int {
/*LALAL*/
return 0
}
}
Audio playback IS implemented on Desktop (via VLC/vlcj library), so received voice messages can be played. Only recording is missing.
Affected Locations
common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt:15-25common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt--RecorderInterface
Impact
Desktop users cannot send voice messages. The record button either does nothing or produces a zero-length file.
Recommendation
- Implement
RecorderNativeusing a JVM audio capture library (e.g.,javax.sound.sampled, or integrate with the existingvlcjdependency for capture). - The output format should match the mobile app's voice message format (likely Opus in an OGG container) for cross-platform compatibility.
- Until implemented, the record button should be hidden or disabled on Desktop with a tooltip explaining the limitation.
Additional Desktop LALAL Placeholders
Several other Desktop features are also marked with LALAL placeholders:
- QR Code Scanner (
QRCodeScanner.desktop.kt:12) -- scanning QR codes is not implemented on Desktop - Animated Drawables (
Utils.desktop.kt:236) --getDrawableFromUrireturns null, soisAnimImagefalls back to the file extension - isImage detection (
Images.desktop.kt:189) -- image type detection (implemented but marked as incomplete)
Desktop cannot decode WebP in chat: decodeBoundedBufferedImage (Utils.desktop.kt:191) reads through ImageIO, which has no WebP reader, so a received .webp renders only as the sender's preview and never opens full screen, and a picked one is skipped. Wallpapers and link previews decode WebP, as they read through Skia instead (Images.desktop.kt:204). Received GIFs do animate in chat items and full screen; the animated image decoder accepts WebP but is never reached for it.
GAP-07: Desktop: Cryptor Not Implemented
Severity: Critical Category: Security / Platform Platform: Desktop only
Description
The CryptorInterface implementation on Desktop is a non-functional placeholder. All three methods are stubbed:
// common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt
actual val cryptor: CryptorInterface = object : CryptorInterface {
override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? {
return String(data) // LALAL
}
override fun encryptText(text: String, alias: String): Pair<ByteArray, ByteArray> {
return text.toByteArray() to text.toByteArray() // LALAL
}
override fun deleteKey(alias: String) {
// LALAL
}
}
decryptDatareturns the data as-is (no decryption)encryptTextreturns the plaintext as both "encrypted data" and "IV"deleteKeyis a no-op
Affected Locations
common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.ktcommon/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt--CryptorInterfacecommon/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt-- usescryptorfor passphrase encryption
Impact
This is a critical security gap. On Desktop:
- The database passphrase is stored in plaintext in the preferences file. Anyone with read access to the user's home directory can extract the passphrase and decrypt the database.
- The self-destruct passphrase is similarly stored in plaintext.
- The app passphrase (for local authentication) provides no real protection.
- Key deletion is a no-op, so "deleting" a key has no effect.
This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self-Destruct Profile) on the Desktop platform.
Recommendation
- Priority: Critical. Implement proper key storage on Desktop using one of:
- OS Keychain integration: macOS Keychain, Windows Credential Manager, Linux Secret Service (via
libsecret/GNOME Keyring/KWallet) - Java Cryptography Architecture (JCA) with a PKCS#12 keystore file protected by a master password
- Bouncy Castle library for platform-independent key management
- OS Keychain integration: macOS Keychain, Windows Credential Manager, Linux Secret Service (via
- Until a real implementation exists, display a prominent warning to Desktop users that their database passphrase is not securely stored.
- Consider requiring the user to enter their passphrase on each app launch (do not store it) as an interim measure.
Related
- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop.
- The
testCrypto()function referenced inAppCommon.desktop.kt:39is commented out with a// LALALmarker, suggesting crypto testing was planned but never completed.
GAP-08: Desktop: App Link Registration
Severity: Low Category: Platform Platform: Desktop only
Description
Desktop registers the simplexchat: scheme so the badge page can return to the app, and the simplex: scheme so connection links open in it. The registration has known limits:
- Uninstalling or deleting the app leaves the per-user registration behind: the HKCU keys on Windows (the scheme keys, the ProgIds,
Capabilitiesand theRegisteredApplicationsentry) and the hidden AppImage desktop entry on Linux. A later link then points at a missing program. On Windows, if the app was installed into a folder other users can write to (for example a custom folder at the root ofC:\), another user could put a program at that path and have it run on the next link; the default Program Files install is not affected. This risk is accepted. - A Flatpak counts as registered without a check, as it cannot query the host's default handler. It works only once the Flathub release pins a commit with the desktop file's MimeType line and its wrapper passes
"$@"to the app. - On macOS a second copy of the app does not forward a link to the running one; macOS delivers the link to the running bundle itself.
- With two installations (a deb and an AppImage, or two installed copies of the Windows app), the scheme stays with whichever registered first while it is installed; the other gets
app=desktop. On Windows, when several apps registered throughCapabilitieshandle a scheme and none was picked, Windows chooses or asks; the app cannot setUserChoiceitself. - The link arrives as a command-line argument, which other local users can read through
/procon Linux. This risk is accepted.
Affected Locations
common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt-- registration and its result for the badge pagecommon/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt-- forwarding to a running instance
Impact
A stale registration or an unregistered Flatpak sends the user back to a link that opens nothing; the badge page's Show code and the Redeem code screen remain.
Recommendation
- Remove the registration in an uninstaller where one exists (MSI), and on AppImage when the entry's file is gone.
- Removing the command-line exposure needs the page to encrypt the code to a key the app passes in the URL fragment.
GAP-09: Desktop: Emoji Picker Without Skin Tones and Localized Search
Severity: Low Category: Feature / Platform Platform: Desktop only
Description
The desktop compose emoji picker lists emoji without skin tone variants, and its search matches English Unicode emoji names only.
Affected Locations
common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.ktscripts/desktop/generate-emoji-data.py(../../scripts/desktop/relative toapps/multiplatform/) -- drops skin tone variants
Impact
Desktop users cannot pick a skin tone in the picker, and search in other languages finds nothing.
Recommendation
- Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker.
- Add localized names (CLDR annotations) to the generated data for search.
GAP-10: Unread Counters Drift
Severity: Low Category: State Platforms: Android, Desktop
Description
The profile unread counter (users[i].unreadCount) is maintained incrementally from events. The core
recomputes it in getUsersInfo, which the client reads through listUsers -- on start, on a user or
remote-host switch, when the user picker opens (and there only if the user list itself differs), and on a
few settings and background-event paths -- but never on a chat-list reload, so reloading the list does not
correct it. The per-tag counts (unreadTags) are recomputed client-side by updateChatTags from the
in-memory chats, but only getUserChatData calls it, so they drift the same way. Several paths change
unread state without a matching counter update.
Affected Locations
ChatModel.kt--removeChatdecrements neither the profile counter norunreadTags, so deleting a chat that had unread messages leaves both high.ChatModel.kt--decreaseCounterInPrimaryContextdecrementsunreadCountbut neverunreadMentions, so clearing a mention in a mentions-only group can leave the chat withunreadCount = 0andunreadMentions = 1.unreadTagstays true whileChatPreviewViewdraws no badge, so the chat sits in the Unread filter with nothing shown. iOS does not share this -- itsremoveChatItemdecrements both.ChatModel.kt--markChatItemsReadhas nosecondaryContextFilterguard, unlike the counter helpers it calls, so with a non-nullitemIdson a secondary context it computeschat.chatStats.unreadCount - markedReadagainst an entry whose stats are always default zero, and can drive that entry negative.
Impact
The unread badge on the profile and on tags can be too high (in the secondary-context case the chat's own
entry in that context goes negative, which ChatView does not draw) until the user switches profile or restarts. A chat that is inside the loaded window has its own count
corrected by the next chat-list reload; a chat outside it (GAP-12) does not.
Recommendation
Decrement both counters in removeChat, pass the mention flag through decreaseCounterInPrimaryContext,
and guard markChatItemsRead on secondaryContextFilter like its siblings. Longer term, re-read
getUsersInfo after operations that clear a whole chat, so the counter has an authoritative source.
GAP-11: addChatItem Writes the Primary List Using the Current Context's Index
Severity: Low Category: State Platforms: Android, Desktop
Description
In ChatModel.kt, addChatItem takes its index from getChatIndex, which searches the current
context's chats, but then reads and writes chatsContext.chats[i] -- the primary list -- for the
in-list branch. Invoked on a secondary context (group reports, member support), the index therefore
addresses a different list than the one it was computed from.
Affected Locations
ChatModel.kt--addChatItem, in-list branch
Impact
A message arriving while a reports or member-support view is open can apply a preview and unread update to whichever primary chat happens to sit at that index: another chat, or the same chat, which then counts the message twice.
Recommendation
Index and write the same list: use chats[i] throughout, or look the chat up by id in the context being
updated.
GAP-12: Chat List: Silent 5000-Chat Limit
Severity: Low Category: Chat List Platforms: Android, Desktop
Description
/_get chats is sent with no pagination, so the core applies its default of PTLast maxChats with
maxChats = 5000. The client neither shows nor records that the list is truncated.
Affected Locations
SimpleXAPI.kt--CC.ApiGetChats.cmdStringbuilds"/_get chats $userId pcc=on", whichapiGetChatssendsSimplex/Chat/Mobile.hs,Simplex/Chat.hs--maxChats = 5000
Impact
On a profile with more than 5000 chats the oldest are not shown, not searched (chat-list search is in-memory) and missing from the in-memory tag counts, with no indication to the user. They reappear only when an event for them arrives (a message, or a contact or group update) or after the list is reloaded with them inside the window.
Recommendation
Either paginate the chat list, or raise the count and surface the limit in the UI.