From eb02e65ec921bb58b421bfbb7479d2f7c3e3f10d Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:15:44 +0000 Subject: [PATCH 1/7] flatpak: update metainfo (#6603) * flatpak: update metainfo * Update scripts/flatpak/chat.simplex.simplex.metainfo.xml Co-authored-by: Evgeny --------- Co-authored-by: Evgeny --- .../flatpak/chat.simplex.simplex.metainfo.xml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 4f5f5d395c..5ebcd08ae9 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.10:

+
    +
  • improve error handling
  • +
+

New in v6.4-6.4.8:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html From 279119e134f2eb81008808a45dbcab253c5653ae Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:29:41 +0000 Subject: [PATCH 2/7] simplex-directory-service: add audio captcha (#6619) * simplex-directory-service: add audio captcha * add plan * updated plan * implement changes * add tests with coverage * add tests * implement further changes * directory tests overview * fix tests on 8.10.7 * /audio command toggles between text and voice captcha * core: /audio enables voice captcha, retry sends both image and voice * remove irrelevant directory service tests * fix flaky testJoinGroup message ordering --- .../src/Directory/Events.hs | 2 + .../src/Directory/Options.hs | 10 + .../src/Directory/Service.hs | 116 +++- cabal.project | 9 + plans/audio-captcha-improvements.md | 520 ++++++++++++++++++ plans/directory-tests-coverage.md | 79 +++ simplex-chat.cabal | 1 + src/Simplex/Chat/Bot.hs | 10 +- tests/Bots/DirectoryTests.hs | 282 +++++++++- 9 files changed, 995 insertions(+), 34 deletions(-) create mode 100644 plans/audio-captcha-improvements.md create mode 100644 plans/directory-tests-coverage.md diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 1f075c677c..45c0b84cc6 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -10,11 +10,13 @@ module Directory.Events ( DirectoryEvent (..), DirectoryCmd (..), + DirectoryCmdTag (..), ADirectoryCmd (..), DirectoryHelpSection (..), DirectoryRole (..), SDirectoryRole (..), crDirectoryEvent, + directoryCmdP, directoryCmdTag, ) where diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index daebd864d6..94305abaa2 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -9,6 +9,7 @@ module Directory.Options ( DirectoryOpts (..), MigrateLog (..), getDirectoryOpts, + directoryOpts, mkChatOpts, ) where @@ -34,6 +35,7 @@ data DirectoryOpts = DirectoryOpts nameSpellingFile :: Maybe FilePath, profileNameLimit :: Int, captchaGenerator :: Maybe FilePath, + voiceCaptchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, @@ -119,6 +121,13 @@ directoryOpts appDir defaultDbName = do <> metavar "CAPTCHA_GENERATOR" <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) + voiceCaptchaGenerator <- + optional $ + strOption + ( long "voice-captcha-generator" + <> metavar "VOICE_CAPTCHA_GENERATOR" + <> help "Executable to generate voice captcha, accepts text as parameter, writes audio file, outputs file_path and duration_seconds to stdout" + ) directoryLog <- optional $ strOption @@ -166,6 +175,7 @@ directoryOpts appDir defaultDbName = do nameSpellingFile, profileNameLimit, captchaGenerator, + voiceCaptchaGenerator, directoryLog, migrateDirectoryLog, serviceName = T.pack serviceName, diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 41ea081890..a6ddc97e19 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -20,11 +20,14 @@ where import Control.Concurrent (forkIO) import Control.Concurrent.STM +import Control.Exception (SomeException, try) import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import qualified Data.Attoparsec.Text as A import Data.Bifunctor (first) +import Data.Either (fromRight) import Data.List (find, intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M @@ -63,13 +66,15 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact) +import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>)) -import System.Directory (getAppUserDataDirectory) +import System.Directory (getAppUserDataDirectory, removeFile) import System.Exit (exitFailure) import System.Process (readProcess) +import Text.Read (readMaybe) data GroupProfileUpdate = GPNoServiceLink @@ -97,10 +102,13 @@ data ServiceState = ServiceState updateListingsJob :: TMVar ChatController } +data CaptchaMode = CMText | CMAudio + data PendingCaptcha = PendingCaptcha { captchaText :: Text, sentAt :: UTCTime, - attempts :: Int + attempts :: Int, + captchaMode :: CaptchaMode } captchaLength :: Int @@ -555,33 +563,57 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName dePendingMember :: GroupInfo -> GroupMember -> IO () dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m - | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText | otherwise = approvePendingMember a g m where a = groupMemberAcceptance g - captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" - sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () - sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do s <- getCaptchaStr captchaLength "" - mc <- getCaptcha s sentAt <- getCurrentTime - let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode} atomically $ TM.insert gmId captcha $ pendingCaptchas env - sendCaptcha mc + case mode of + CMAudio -> do + mc <- getCaptchaContent s + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + sendVoiceCaptcha sendRef s + CMText -> do + mc <- getCaptchaContent s + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] where - getCaptcha s = case captchaGenerator opts of - Nothing -> pure textMsg - Just script -> content <$> readProcess script [s] "" - where - textMsg = MCText $ T.pack s - content r = case T.lines $ T.pack r of - [] -> textMsg - "" : _ -> textMsg - img : _ -> MCImage "" $ ImageData img - sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)] + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) gmId = groupMemberId' m + sendVoiceCaptcha :: SendRef -> String -> IO () + sendVoiceCaptcha sendRef s = + forM_ (voiceCaptchaGenerator opts) $ \script -> + void . forkIO $ do + voiceResult <- try $ readProcess script [s] "" :: IO (Either SomeException String) + case voiceResult of + Right r -> case lines r of + (filePath : durationStr : _) + | not (null filePath), Just duration <- readMaybe durationStr -> do + sendComposedMessageFile cc sendRef Nothing (MCVoice "" duration) (CF.plain filePath) + void (try $ removeFile filePath :: IO (Either SomeException ())) + _ -> logError "voice captcha generator: unexpected output" + Left e -> logError $ "voice captcha generator error: " <> tshow e + + getCaptchaContent :: String -> IO MsgContent + getCaptchaContent s = case captchaGenerator opts of + Nothing -> pure $ MCText $ T.pack s + Just script -> content <$> readProcess script [s] "" + where + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + textMsg = MCText $ T.pack s + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) @@ -598,16 +630,34 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText | memberRequiresCaptcha a m = do - ts <- getCurrentTime - atomically (TM.lookup (groupMemberId' m) $ pendingCaptchas env) >>= \case - Just PendingCaptcha {captchaText, sentAt, attempts} - | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 - | matchCaptchaStr captchaText msgText -> do - sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] - approvePendingMember a g m - | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts - | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts - Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + let gmId = groupMemberId' m + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + -- /audio is matched as text, not as DirectoryCmd, because it is only valid + -- in group context at captcha stage, while DirectoryCmd is for DM commands. + isAudioCmd = T.strip msgText == "/audio" + cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Nothing -> + let mode = if isAudioCmd then CMAudio else CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} + | isAudioCmd -> case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + | otherwise -> case cmd of + ADC SDRUser (DCSearchGroup _) -> do + ts <- getCurrentTime + if + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode + _ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)] | otherwise = approvePendingMember a g m where a = groupMemberAcceptance g @@ -619,11 +669,19 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired :: Text captchaExpired = "Captcha expired, please try again." + wrongCaptcha :: Int -> Text wrongCaptcha attempts | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." | otherwise = "Incorrect text, please try again." + noCaptcha :: Text noCaptcha = "Unexpected message, please try again." + audioAlreadyEnabled :: Text + audioAlreadyEnabled = "Audio captcha is already enabled." + unknownCommand :: Text + unknownCommand = "Unknown command, please enter captcha text." + tooManyAttempts :: Text tooManyAttempts = "Too many failed attempts, you can't join group." memberRequiresCaptcha :: DirectoryMemberAcceptance -> GroupMember -> Bool diff --git a/cabal.project b/cabal.project index fc61a8c077..0b2104ba76 100644 --- a/cabal.project +++ b/cabal.project @@ -2,6 +2,15 @@ packages: . -- packages: . ../simplexmq -- packages: . ../simplexmq ../direct-sqlcipher ../sqlcipher-simple +-- uncomment two sections below to run tests with coverage +-- package * +-- coverage: True +-- library-coverage: True + +-- package attoparsec +-- coverage: False +-- library-coverage: False + index-state: 2023-12-12T00:00:00Z package cryptostore diff --git a/plans/audio-captcha-improvements.md b/plans/audio-captcha-improvements.md new file mode 100644 index 0000000000..6797115396 --- /dev/null +++ b/plans/audio-captcha-improvements.md @@ -0,0 +1,520 @@ +# Audio Captcha Improvements Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [High-Level Design](#high-level-design) +3. [Detailed Implementation Plan](#detailed-implementation-plan) +4. [Test Updates](#test-updates) +5. [Files Changed](#files-changed) + +--- + +## Executive Summary + +Improve the audio captcha feature by: + +1. **Proper command parsing** — add `DCCaptchaMode CaptchaMode` constructor to `DirectoryCmd` GADT, using existing Attoparsec parsing infrastructure +2. **Audio captcha retry** — when user switches to audio mode, subsequent retries send voice captcha (not image) +3. **Make `/audio` clickable** — use `/'audio'` format for clickable command in chat UI + +--- + +## High-Level Design + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ CaptchaMode (Events.hs) │ +├──────────────────────────────────────────────────────────────────┤ +│ CMText -- default image/text captcha │ +│ CMAudio -- voice captcha mode │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ PendingCaptcha State │ +├──────────────────────────────────────────────────────────────────┤ +│ captchaText :: Text -- the captcha answer │ +│ sentAt :: UTCTime -- when captcha was sent │ +│ attempts :: Int -- number of attempts │ +│ captchaMode :: CaptchaMode -- current mode (CMText/CMAudio) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ DirectoryCmd (Events.hs) │ +├──────────────────────────────────────────────────────────────────┤ +│ DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser │ +│ (integrated into existing GADT, parsed via directoryCmdP) │ +└──────────────────────────────────────────────────────────────────┘ + +Flow: +1. User joins group → sendMemberCaptcha (image) + captchaNotice with /'audio' +2. User sends /audio → parsed as DCCaptchaMode CMAudio → set captchaMode=CMAudio, sendVoiceCaptcha +3. User sends wrong answer: + - captchaMode=CMText → send new IMAGE captcha + - captchaMode=CMAudio → send new VOICE captcha ← NEW BEHAVIOR +4. User sends correct answer → approve member + +Message parsing flow (in Service.hs dePendingMemberMsg): +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Parse msgText with directoryCmdP (existing infrastructure) │ +│ ↓ │ +│ 2. TM.lookup pendingCaptcha (ONCE, not per-branch) │ +│ ↓ │ +│ ├─ Nothing → sendMemberCaptcha with mode from parsed cmd │ +│ └─ Just pc → case on parsed cmd: │ +│ ├─ DCCaptchaMode CMAudio → set mode, send voice captcha │ +│ ├─ DCSearchGroup _ → captcha answer (verify/retry) │ +│ └─ _ → unknown command (error message) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Detailed Implementation Plan + +### 3.1 Add `CaptchaMode` type in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** After `DirectoryHelpSection` (line 146) + +**Add:** +```haskell +data CaptchaMode = CMText | CMAudio + deriving (Show) +``` + +**Update exports (line 10-19):** +```haskell +module Directory.Events + ( DirectoryEvent (..), + DirectoryCmd (..), + ADirectoryCmd (..), + DirectoryHelpSection (..), + CaptchaMode (..), + DirectoryRole (..), + SDirectoryRole (..), + crDirectoryEvent, + directoryCmdP, + directoryCmdTag, + ) +where +``` + +--- + +### 3.2 Add `DCCaptchaMode_` tag in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `DirectoryCmdTag` GADT (after line 127, before admin commands) + +**Add:** +```haskell + DCCaptchaMode_ :: DirectoryCmdTag 'DRUser +``` + +--- + +### 3.3 Add `DCCaptchaMode` constructor in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `DirectoryCmd` GADT (after line 160, with other user commands) + +**Add:** +```haskell + DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser +``` + +--- + +### 3.4 Add "audio" tag parsing in Events.hs + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `tagP` function (after line 205, in user commands section) + +**Add:** +```haskell + "audio" -> u DCCaptchaMode_ +``` + +--- + +### 3.5 Add `DCCaptchaMode_` case in `cmdP` + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `cmdP` function (after line 237, with other simple commands) + +**Add:** +```haskell + DCCaptchaMode_ -> pure $ DCCaptchaMode CMAudio +``` + +--- + +### 3.6 Add `DCCaptchaMode` case in `directoryCmdTag` + +**File:** `apps/simplex-directory-service/src/Directory/Events.hs` + +**Location:** In `directoryCmdTag` function (after line 316) + +**Add:** +```haskell + DCCaptchaMode _ -> "audio" +``` + +--- + +### 3.7 Update `PendingCaptcha` with `captchaMode` field + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Lines 103-107 + +**Before:** +```haskell +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int + } +``` + +**After:** +```haskell +data PendingCaptcha = PendingCaptcha + { captchaText :: Text, + sentAt :: UTCTime, + attempts :: Int, + captchaMode :: CaptchaMode + } +``` + +--- + +### 3.8 Update import in Service.hs + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Line 41 + +**Before:** +```haskell +import Directory.Events +``` + +**After (no change needed):** The implicit import already imports all exports including the new `CaptchaMode`. + +--- + +### 3.9 Update `sendMemberCaptcha` signature and implementation + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Function `sendMemberCaptcha` (lines 569-589) + +**Before:** +```haskell + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts = do + s <- getCaptchaStr captchaLength "" + mc <- getCaptcha s + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + sendCaptcha mc + where + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m +``` + +**After:** +```haskell + sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () + sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do + s <- getCaptchaStr captchaLength "" + sentAt <- getCurrentTime + let captcha = PendingCaptcha {captchaText = T.pack s, sentAt, attempts = prevAttempts + 1, captchaMode = mode} + atomically $ TM.insert gmId captcha $ pendingCaptchas env + case mode of + CMAudio -> do + sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText)] + sendVoiceCaptcha sendRef s + CMText -> do + mc <- getCaptcha s + sendCaptcha mc + where + getCaptcha s = case captchaGenerator opts of + Nothing -> pure textMsg + Just script -> content <$> readProcess script [s] "" + where + textMsg = MCText $ T.pack s + content r = case T.lines $ T.pack r of + [] -> textMsg + "" : _ -> textMsg + img : _ -> MCImage "" $ ImageData img + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendCaptcha mc = sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] + gmId = groupMemberId' m +``` + +--- + +### 3.10 Update `dePendingMember` call site + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** Line 561 + +**Before:** +```haskell + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 +``` + +**After:** +```haskell + | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 CMText +``` + +--- + +### 3.11 Make `/audio` clickable in `captchaNotice` + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** `dePendingMember` function, `captchaNotice` definition (lines 565-567) + +**Before:** +```haskell + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +**After:** +```haskell + captchaNotice = + "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." + <> if isJust (voiceCaptchaGenerator opts) then "\nSend /'audio' to receive a voice captcha." else "" +``` + +--- + +### 3.12 Refactor `dePendingMemberMsg` with inverted structure + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** `dePendingMemberMsg` function (lines 618-656) + +**Key changes:** +1. Parse command FIRST using existing `directoryCmdP` +2. Do TM.lookup ONCE (not per-branch) +3. Case on lookup result, then on command inside + +**Before:** +```haskell + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + let gmId = groupMemberId' m + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + if T.toLower (T.strip msgText) == "/audio" + then + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText} -> + sendVoiceCaptcha sendRef (T.unpack captchaText) + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + else do + ts <- getCurrentTime + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Just PendingCaptcha {captchaText, sentAt, attempts} + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts + Nothing -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + tooManyAttempts = "Too many failed attempts, you can't join group." +``` + +**After:** +```haskell + dePendingMemberMsg :: GroupInfo -> GroupMember -> ChatItemId -> Text -> IO () + dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText + | memberRequiresCaptcha a m = do + let gmId = groupMemberId' m + sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText + atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case + Nothing -> + let mode = case cmd of ADC SDRUser (DCCaptchaMode CMAudio) -> CMAudio; _ -> CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} -> case cmd of + ADC SDRUser (DCCaptchaMode CMAudio) -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + ADC SDRUser (DCSearchGroup _) -> do + ts <- getCurrentTime + if + | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired (attempts - 1) captchaMode + | matchCaptchaStr captchaText msgText -> do + sendComposedMessages_ cc sendRef [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + approvePendingMember a g m + | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts + | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts captchaMode + _ -> sendComposedMessages_ cc sendRef [(Just ciId, MCText unknownCommand)] + | otherwise = approvePendingMember a g m + where + a = groupMemberAcceptance g + rejectPendingMember rjctNotice = do + let gmId = groupMemberId' m + sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case + Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + atomically $ TM.delete gmId $ pendingCaptchas env + logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g + r -> logError $ "unexpected remove member response: " <> tshow r + captchaExpired = "Captcha expired, please try again." + wrongCaptcha attempts + | attempts == maxCaptchaAttempts - 1 = "Incorrect text, please try again - this is your last attempt." + | otherwise = "Incorrect text, please try again." + noCaptcha = "Unexpected message, please try again." + unknownCommand = "Unknown command, please enter captcha text." + tooManyAttempts = "Too many failed attempts, you can't join group." +``` + +--- + +### 3.13 Add imports in Service.hs + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +**Location:** After existing imports (around line 28) + +**Add:** +```haskell +import qualified Data.Attoparsec.Text as A +import Data.Either (fromRight) +``` + +**Note:** `T.strip` is already available via the existing `import qualified Data.Text as T`. + +--- + +## Test Updates + +**File:** `tests/Bots/DirectoryTests.hs` + +### 4.1 Update expected output for clickable command + +**Location:** Line 1278 (or wherever `"Send /audio"` appears) + +**Before:** +```haskell +cath <## "Send /audio to receive a voice captcha." +``` + +**After:** +```haskell +cath <## "Send /'audio' to receive a voice captcha." +``` + +### 4.2 Add test for audio captcha retry behavior + +**Location:** New test function `testVoiceCaptchaRetry` after `testVoiceCaptchaScreening` + +**Strategy:** Add test that verifies wrong answer after `/audio` sends voice retry (not image). + +```haskell +testVoiceCaptchaRetry :: HasCallStack => TestParams -> IO () +testVoiceCaptchaRetry ps = do + -- Setup similar to testVoiceCaptchaScreening... + -- After receiving initial image captcha and switching to audio: + -- cath requests audio captcha + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + -- cath sends WRONG answer after switching to audio mode + cath #> "#privacy (support) wrong_answer" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong_answer" + cath <## " Incorrect text, please try again." + -- KEY ASSERTION: retry sends VOICE captcha (not image) because captchaMode=CMAudio + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 2" +``` + +--- + +## Files Changed + +| File | Changes | +|------|---------| +| `apps/simplex-directory-service/src/Directory/Events.hs` | Add `CaptchaMode` type; add `DCCaptchaMode_` tag; add `DCCaptchaMode` constructor; add "audio" tag parsing; add `cmdP` case; add `directoryCmdTag` case; export `directoryCmdP`; update exports | +| `apps/simplex-directory-service/src/Directory/Service.hs` | Add imports (`Data.Attoparsec.Text`, `Data.Either.fromRight`); update `PendingCaptcha` with `captchaMode :: CaptchaMode`; update `sendMemberCaptcha` signature; refactor `dePendingMemberMsg` with inverted structure; make `/audio` clickable | +| `tests/Bots/DirectoryTests.hs` | Update expected output (`/'audio'`); add `testVoiceCaptchaRetry` | + +--- + +## Summary of Changes + +1. **New type in Events.hs:** + - `data CaptchaMode = CMText | CMAudio` + +2. **New constructor in DirectoryCmd GADT:** + - `DCCaptchaMode :: CaptchaMode -> DirectoryCmd 'DRUser` + - Uses existing Attoparsec parsing infrastructure via `directoryCmdP` + +3. **State tracking (Service.hs):** + - `PendingCaptcha { ..., captchaMode :: CaptchaMode }` + +4. **Refactored `dePendingMemberMsg` (Service.hs):** + - Parses command FIRST using `directoryCmdP` + - Does `TM.lookup` ONCE (inverted structure, no duplication) + - `Nothing` case: send new captcha in mode derived from command + - `Just pc` case: switch on command type + - `DCCaptchaMode CMAudio` → set mode, send voice captcha + - `DCSearchGroup _` → captcha answer (verify/retry) + - `_` → unknown command (error message) + +5. **Updated `sendMemberCaptcha` (Service.hs):** + - Takes `CaptchaMode` parameter instead of `Bool` + - Sends voice or image captcha based on mode + +6. **Clickable command:** + - `"Send /'audio'"` instead of `"Send /audio"` + +7. **Test coverage:** + - `testVoiceCaptchaScreening` (updated): verify clickable command format + - `testVoiceCaptchaRetry` (new): verify retry behavior with `captchaMode` persistence diff --git a/plans/directory-tests-coverage.md b/plans/directory-tests-coverage.md new file mode 100644 index 0000000000..a17d8b379a --- /dev/null +++ b/plans/directory-tests-coverage.md @@ -0,0 +1,79 @@ +# Directory Modules: Test Coverage Report + +## Final Coverage + +| Module | Expressions | Coverage | Gap | +|---|---|---|---| +| **Captcha** | 84/84 | **100%** | -- | +| **Search** | 3/3 | **100%** | -- | +| **BlockedWords** | 158/158 | **100%** | -- | +| **Events** | 527/559 | **94%** | 32 expr | +| **Options** | 223/291 | **76%** | 68 expr | +| **Store** | 1137/1306 | **87%** | 169 expr | +| **Listing** | 379/650 | **58%** | 271 expr | + +84 tests, 0 failures. + +## What was covered + +Tests added to `tests/Bots/DirectoryTests.hs`: + +- **Search**: `SearchRequest` field selectors (`searchType`, `searchTime`, `lastGroup`) +- **BlockedWords**: `BlockedWordsConfig` field selectors, `removeTriples` with `'\0'` input to force initial `False` argument +- **Options**: `directoryOpts` parser via `execParserPure` (minimal args, non-default args, all `MigrateLog` variants), `mkChatOpts` remaining fields +- **Events**: command parser edge cases (`/`, `/filter 1 name=all`, `/submit`, moderate/strong presets), `Show` instances for `DirectoryCmdTag`, `DirectoryCmd`, `SDirectoryRole`, `DirectoryHelpSection`, `DirectoryEvent`, `ADirectoryCmd` (including `showList`), `DCApproveGroup` field selectors via `OverloadedRecordDot`, `CEvtChatErrors` path +- **Store**: `Show` instances for `GroupRegStatus` constructors, `ProfileCondition`, `noJoinFilter`, `GroupReg.createdAt` field +- **Listing**: `DirectoryEntryType` JSON round-trip with field selectors + +Source changes: + +- `Directory/Options.hs`: exported `directoryOpts` +- `Directory/Events.hs`: exported `DirectoryCmdTag (..)` + +## Why not 100% + +### Events (32 expr remaining) + +**Field selectors (9 expr)** on `DEGroupInvitation`, `DEServiceJoinedGroup`, `DEGroupUpdated` -- need `Contact`, `GroupInfo`, `GroupMember` types which have 20+ nested required fields each with no test constructors available. + +**`crDirectoryEvent_` branches (3 expr)**: `DEItemDeleteIgnored`, `DEUnsupportedMessage`, `CEvtMessageError` -- need `AChatItem` or `User`, both strict-data types with deep dependency chains impossible to construct in unit tests. + +**`DCSubmitGroup` paths (2 expr)**: constructor and `directoryCmdTag` case -- need a valid `ConnReqContact` (SMP queue URI with cryptographic keys). + +**Lazy `fail` strings (2 expr)**: `"bad command tag"` and `"bad help section"` -- Attoparsec discards the string argument to `fail` without evaluating it. Inherently uncoverable by HPC. + +### Options (68 expr remaining) + +**Parser metadata strings (~50 expr)**: `metavar` and `help` string literals in `optparse-applicative` option declarations are evaluated lazily by the library. `execParserPure` constructs the parser but doesn't force help strings unless `--help` is invoked. + +**`getDirectoryOpts` (~10 expr)**: wraps `execParser` which reads process `argv` -- can't unit-test without spawning a process. + +**`parseKnownGroup` internals (~8 expr)**: the `--owners-group` arg is parsed but the `KnownContacts` parser internals are instrumented separately. + +### Store (169 expr remaining) + +**DB operations (~150 expr)**: `withDB'` wrappers, SQL query strings, error message literals inside database functions (`setGroupStatusStore`, `setGroupRegOwnerStore`, `searchListedGroups`, `getAllGroupRegs_`, etc.) -- all require a running SQLite database with realistic data. + +**Pagination branches (~15 expr)**: `searchListedGroups` and `getAllGroupRegs_` cursor pagination -- need multi-page result sets. + +**Parser failure (~4 expr)**: `GroupRegStatus` `strDecode` failure path -- needs malformed stored data. + +### Listing (271 expr remaining) + +**Image processing (~80 expr)**: `imgFileData`, image file Base64 encoding paths -- require groups with profile images. + +**Listing generation (~120 expr)**: `generateListing`, `groupDirectoryEntry` -- require `GroupInfo` (21+ fields), `GroupLink`, `CreatedLinkContact` types with deep nesting into chat protocol internals. + +**Field selectors (~40 expr)**: `DirectoryEntry` fields (`displayName`, `fullName`, `image`, `memberCount`, etc.) -- need full `DirectoryEntry` construction which requires `CreatedLinkContact`. + +**TH-generated JSON (~30 expr)**: Template Haskell `deriveJSON` expressions are marked as runtime-uncovered by HPC despite executing at compile time. + +## Summary + +All remaining gaps fall into three categories: + +1. **DB integration paths** -- require a running database (Store) +2. **Complex chat protocol types** -- types with 20+ required nested fields (Events, Listing) +3. **Lazy evaluation artifacts** -- HPC can't observe values that are never forced at runtime (Options `help` strings, Attoparsec `fail` strings, TH-generated code) + +None are testable with pure unit tests without either standing up a database or constructing massive type hierarchies. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 5f2fd70eab..c85bc61ae0 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -600,6 +600,7 @@ test-suite simplex-chat-test apps/simplex-directory-service/src default-extensions: StrictData + -- add -fhpc to ghc-options below to run tests with coverage ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: QuickCheck ==2.14.* diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index ff14dae6db..3aca687ec5 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -12,7 +12,7 @@ import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad import qualified Data.ByteString.Char8 as B -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import qualified Data.Map.Strict as M import Data.Maybe (isJust) @@ -26,6 +26,7 @@ import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..)) import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..)) +import Simplex.Messaging.Crypto.File (CryptoFile) import Simplex.Messaging.Encoding.String (strEncode) import System.Exit (exitFailure) @@ -89,6 +90,13 @@ sendComposedMessages_ cc sendRef qmcs = do Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent " <> show (length cms) <> " messages to " <> show sendRef r -> putStrLn $ "unexpected send message response: " <> show r +sendComposedMessageFile :: ChatController -> SendRef -> Maybe ChatItemId -> MsgContent -> CryptoFile -> IO () +sendComposedMessageFile cc sendRef qiId mc file = do + let cm = ComposedMessage {fileSource = Just file, quotedItemId = qiId, msgContent = mc, mentions = M.empty} + sendChatCmd cc (APISendMessages sendRef False Nothing (cm :| [])) >>= \case + Right (CRNewChatItems {}) -> printLog cc CLLInfo $ "sent file message to " <> show sendRef + r -> putStrLn $ "unexpected send message response: " <> show r + deleteMessage :: ChatController -> Contact -> ChatItemId -> IO () deleteMessage cc ct chatItemId = do let cmd = APIDeleteChatItem (contactRef ct) [chatItemId] CIDMInternal diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 1f183910bb..284a069bab 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -19,7 +19,8 @@ import Directory.Listing import Directory.Options import Directory.Service import Directory.Store -import GHC.IO.Handle (hClose) +import System.Directory (emptyPermissions, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions) +import System.IO (hClose) import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD @@ -70,10 +71,18 @@ directoryServiceTests = do it "should list and promote user's groups" $ testListUserGroups True describe "member admission" $ do it "should ask member to pass captcha screen" testCapthaScreening + it "should send voice captcha on /audio command" testVoiceCaptchaScreening + it "should retry with voice captcha after switching to audio mode" testVoiceCaptchaRetry + it "should reject member after too many captcha attempts" testCaptchaTooManyAttempts + it "should respond to unknown command during captcha" testCaptchaUnknownCommand describe "store log" $ do it "should restore directory service state" testRestoreDirectory describe "captcha" $ do it "should accept some incorrect spellings" testCaptcha + it "should generate captcha of correct length" testGetCaptchaStr + describe "help commands" $ do + it "should not list audio command" testHelpNoAudio + it "should reject audio command in DM" testAudioCommandInDM directoryProfile :: Profile directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} @@ -102,6 +111,7 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = nameSpellingFile = Nothing, profileNameLimit = maxBound, captchaGenerator = Nothing, + voiceCaptchaGenerator = Nothing, directoryLog = Just $ ps "directory_service.log", migrateDirectoryLog = Nothing, serviceName = "SimpleX Directory", @@ -404,9 +414,11 @@ testJoinGroup ps = cath <## "connection request sent!" cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" - cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" - cath <## "use @'SimpleX Directory' to send messages" - cath <# ("#privacy 'SimpleX Directory'> " <> welcomeMsg) + cath + <### [ "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'", + "use @'SimpleX Directory' to send messages", + Predicate (\l -> l == welcomeMsg || dropTime_ l == Just ("#privacy 'SimpleX Directory'> " <> welcomeMsg) || dropTime_ l == Just ("#privacy 'SimpleX Directory_1'> " <> welcomeMsg)) + ] cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1225,6 +1237,152 @@ testCapthaScreening ps = cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" +testVoiceCaptchaScreening :: HasCallStack => TestParams -> IO () +testVoiceCaptchaScreening ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen.py" + -- Mock script writes a dummy audio file, prints path and duration + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + -- get group link + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + -- enable captcha + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- cath joins, receives text captcha with /audio hint + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + cath <## "Send /audio to receive a voice captcha." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- cath requests audio captcha + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + -- cath sends /audio again, already enabled + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath /audio" + cath <## " Audio captcha is already enabled." + -- send correct captcha + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + +testVoiceCaptchaRetry :: HasCallStack => TestParams -> IO () +testVoiceCaptchaRetry ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_retry.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- cath joins, receives text captcha with /audio hint + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + cath <## "Send /audio to receive a voice captcha." + _ <- getTermLine cath -- captcha image/text + -- cath requests audio captcha + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + -- cath sends WRONG answer after switching to audio mode + cath #> "#privacy (support) wrong_answer" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong_answer" + cath <## " Incorrect text, please try again." + -- KEY ASSERTION: retry sends BOTH image and voice because captchaMode=CMAudio + _ <- getTermLine cath -- captcha image/text + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 2" + +withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceVoiceCaptcha ps voiceScript test = do + dsLink <- + withNewTestChatCfg ps testCfg serviceDbPrefix directoryProfile $ \ds -> + withNewTestChatCfg ps testCfg "super_user" aliceProfile $ \superUser -> do + connectUsers ds superUser + ds ##> "/ad" + getContactLink ds True + let opts = (mkDirectoryOpts ps [KnownContact 2 "alice"] Nothing Nothing) {voiceCaptchaGenerator = Just voiceScript} + runDirectory testCfg opts $ + withTestChatCfg ps testCfg "super_user" $ \superUser -> do + superUser <## "subscribed 1 connections on server localhost" + test superUser dsLink + testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do testListUserGroups False ps @@ -1538,3 +1696,119 @@ groupNotFound_ suffix u s = do u #> ("@'SimpleX Directory" <> suffix <> "' " <> s) u <# ("'SimpleX Directory" <> suffix <> "'> > " <> s) u <## " No groups found" + +testCaptchaTooManyAttempts :: HasCallStack => TestParams -> IO () +testCaptchaTooManyAttempts ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + _ <- getTermLine cath + forM_ [1 :: Int .. 4] $ \i -> do + cath #> "#privacy (support) wrong" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath wrong" + if i == 4 + then cath <## " Incorrect text, please try again - this is your last attempt." + else cath <## " Incorrect text, please try again." + _ <- getTermLine cath + pure () + cath #> "#privacy (support) wrong" + cath <# "#privacy (support) 'SimpleX Directory'> Too many failed attempts, you can't join group." + -- member removal produces multiple messages + _ <- getTermLine cath + _ <- getTermLine cath + _ <- getTermLine cath + pure () + +testCaptchaUnknownCommand :: HasCallStack => TestParams -> IO () +testCaptchaUnknownCommand ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + _ <- getTermLine cath + cath #> "#privacy (support) /help" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath /help" + cath <## " Unknown command, please enter captcha text." + +testHelpNoAudio :: HasCallStack => TestParams -> IO () +testHelpNoAudio ps = + withDirectoryService ps $ \_ dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + -- commands help should not mention /audio + bob #> "@'SimpleX Directory' /help commands" + bob <# "'SimpleX Directory'> /'help commands' - receive this help message." + bob <## "/help - how to register your group to be added to directory." + bob <## "/list - list the groups you registered." + bob <## "`/role ` - view and set default member role for your group." + bob <## "`/filter ` - view and set spam filter settings for group." + bob <## "`/link ` - view and upgrade group link." + bob <## "`/delete :` - remove the group you submitted from directory, with ID and name as shown by /list command." + bob <## "" + bob <## "To search for groups, send the search text." + +testAudioCommandInDM :: HasCallStack => TestParams -> IO () +testAudioCommandInDM ps = + withDirectoryService ps $ \_ dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + bob #> "@'SimpleX Directory' /audio" + bob <# "'SimpleX Directory'> > /audio" + bob <## " Unknown command" + +testGetCaptchaStr :: HasCallStack => TestParams -> IO () +testGetCaptchaStr _ps = do + s0 <- getCaptchaStr 0 "" + s0 `shouldBe` "" + s7 <- getCaptchaStr 7 "" + length s7 `shouldBe` 7 + all (`elem` ("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" :: String)) s7 `shouldBe` True From 894368dac89dfde83949076f21cf1bbefa87b020 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 9 Feb 2026 18:58:14 +0000 Subject: [PATCH 3/7] website: update transparency report --- docs/TRANSPARENCY.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md index bd0dcabb53..c3e74d6b28 100644 --- a/docs/TRANSPARENCY.md +++ b/docs/TRANSPARENCY.md @@ -1,18 +1,18 @@ --- title: Transparency Reports permalink: /transparency/index.html -revision: 15.01.2025 +revision: 09.02.2026 --- # Transparency Reports -**Updated**: Jan 15, 2025 +**Updated**: Feb 09, 2026 SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. This page will include any and all reports on requests for user data. -*To date, we received none*. +In 2025 we received 12 requests from law enforcement of different countries. No responsive information was identified/provided. In 2024 we received enquiries from several law enforcement agencies seeking information on our procedures for handling data requests. We responded by noting that we operate under the UK law and will consider such requests pursuant to UK law. @@ -29,6 +29,6 @@ Our objective is to consistently ensure that no user data and absolute minimum o - Trail of Bits, SimpleX cryptography and networking, [October 2022](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). - Trail of Bits, the cryptographic review of SimpleX protocols design, [July 2024](../blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md). -Have a more specific question? Reach out to us via [SimpleX Chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) or via email [chat@simplex.chat](mailto:chat@simplex.chat). +Have a more specific question? Reach out to us via [SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw) or via email [chat@simplex.chat](mailto:chat@simplex.chat). For any sensitive questions please use SimpleX Chat or encrypted email messages using the key for this address from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and make your key available for a secure reply. From 5b90a85b2c358c6e9052b52adb9a8a137e253ce7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 9 Feb 2026 19:20:15 +0000 Subject: [PATCH 4/7] website: fix build --- website/web.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/website/web.sh b/website/web.sh index 34bbf0b3c6..cc08ae57e9 100755 --- a/website/web.sh +++ b/website/web.sh @@ -3,6 +3,7 @@ set -e cp -R docs website/src +rm -rf website/src/docs/contributing rm -rf website/src/docs/rfcs rm website/src/docs/lang/*/README.md rm -rf website/src/docs/dependencies From 764fb27f1cc7d0a0b25b62716d215983bc09a006 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:26:18 +0000 Subject: [PATCH 5/7] core, directory: allow voice messages during member approval phase to allow audio captchas in groups that prohibit voice messages (#6624) * rfcs: add member-support-voice rfc * update based on the feedback * implement RFC * add new tests * fix protocol tests and update plans * restrict voice captcha exemption to host approval phase * update agent_query_plans.txt --- .../src/Directory/Service.hs | 33 ++- docs/rfcs/2026-02-10-member-support-voice.md | 212 ++++++++++++++++++ src/Simplex/Chat/Library/Internal.hs | 13 +- src/Simplex/Chat/Protocol.hs | 7 +- .../SQLite/Migrations/agent_query_plans.txt | 4 - .../SQLite/Migrations/chat_query_plans.txt | 8 - tests/Bots/DirectoryTests.hs | 135 +++++++++++ tests/ProtocolTests.hs | 8 +- 8 files changed, 390 insertions(+), 30 deletions(-) create mode 100644 docs/rfcs/2026-02-10-member-support-voice.md diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a6ddc97e19..6666de7587 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -54,7 +54,7 @@ import Simplex.Chat.Core import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) @@ -569,7 +569,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." - <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" + <> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do @@ -614,6 +614,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName img : _ -> MCImage "" $ ImageData img textMsg = MCText $ T.pack s + canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool + canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) @@ -637,16 +642,20 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAudioCmd = T.strip msgText == "/audio" cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case - Nothing -> - let mode = if isAudioCmd then CMAudio else CMText - in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Nothing + | isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + | isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} - | isAudioCmd -> case captchaMode of - CMText -> do - atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env - sendVoiceCaptcha sendRef (T.unpack captchaText) - CMAudio -> - sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + | isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] | otherwise -> case cmd of ADC SDRUser (DCSearchGroup _) -> do ts <- getCurrentTime @@ -679,6 +688,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName noCaptcha = "Unexpected message, please try again." audioAlreadyEnabled :: Text audioAlreadyEnabled = "Audio captcha is already enabled." + voiceCaptchaUnavailable :: Text + voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." unknownCommand :: Text unknownCommand = "Unknown command, please enter captcha text." tooManyAttempts :: Text diff --git a/docs/rfcs/2026-02-10-member-support-voice.md b/docs/rfcs/2026-02-10-member-support-voice.md new file mode 100644 index 0000000000..52285d1514 --- /dev/null +++ b/docs/rfcs/2026-02-10-member-support-voice.md @@ -0,0 +1,212 @@ +# Voice messages in member support scope + +## Table of contents + +1. Executive summary +2. Problem +3. High-level design +4. Detailed implementation plan + +## 1. Executive summary + +Allow voice messages from host/admin during the approval phase (member pending) regardless of group voice settings, gated behind chat protocol version 17. This enables the directory bot to send voice captchas in groups that prohibit voice messages. Old clients that don't support this exemption will receive text/image captchas instead. + +## 2. Problem + +The directory bot sends voice captchas to joining members via the member support scope (`GCSMemberSupport`). However, `prohibitedGroupContent` (Internal.hs:338) blocks voice messages when the group disables voice — with no scope exemption: + +```haskell +| isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +Other content types (files, reports, simplex links) already have `isNothing scopeInfo` guards that exempt them in member support scope. Voice does not. + +This means voice captchas fail in the majority of real groups that prohibit voice messages. The check runs on both sender side (Commands.hs:3856) and recipient side (Subscriber.hs:1738), so both the bot and the joining member reject voice in these groups. + +## 3. High-level design + +1. **Protocol version 17** (`memberSupportVoiceVersion`): gates the `prohibitedGroupContent` exemption for host voice during the approval phase. + +2. **Core library change** (Internal.hs): exempt voice in `prohibitedGroupContent` when sender is admin+ (host) AND the member is in the approval phase (pending status). Voice is NOT generally allowed in member support scope — only during approval, only from host. + +3. **Directory bot change** (Service.hs): check member's protocol version and group voice settings before offering or sending voice captcha. Fall back to text/image captcha for old clients in voice-disabled groups. + +## 4. Detailed implementation plan + +### 4.1. Protocol.hs — add version 17 + +**File:** `src/Simplex/Chat/Protocol.hs` + +Add to version history comment (after line 79): + +``` +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +``` + +Update `currentChatVersion` (line 85): + +```haskell +currentChatVersion = VersionChat 17 +``` + +Add version constant (after `shortLinkDataVersion`, line 146): + +```haskell +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 +``` + +### 4.2. Internal.hs — exempt host voice during approval phase + +**File:** `src/Simplex/Chat/Library/Internal.hs` + +Change function header (line 337) to bind sender's role and full membership: + +```haskell +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m@GroupMember {memberRole = senderRole} scopeInfo mc ft file_ sent +``` + +Change line 338 from: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +to: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice +``` + +Add to the `where` clause: + +```haskell + hostApprovalVoice = senderRole >= GRAdmin && inApprovalPhase + inApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberPending scopeMem + Just (GCSIMemberSupport Nothing) -> memberPending mem + Nothing -> False +``` + +Note: `memberPending` returns True for both `GSMemPendingApproval` and `GSMemPendingReview`. The exemption applies to both phases — the member hasn't been fully admitted in either state. + +**Why two cases for `inApprovalPhase`:** + +- **Sender side** (bot sending via Commands.hs:3856): `scopeInfo = GCSIMemberSupport (Just pendingMember)` — the scope contains the pending member being supported. `memberPending pendingMember` checks their status. +- **Receiver side** (member receiving via Subscriber.hs:1738): `scopeInfo = GCSIMemberSupport Nothing` — `Nothing` means the member's own support conversation (constructed by `mkGroupSupportChatInfo` in Internal.hs:1535). `memberPending mem` checks the local user's (receiving member's) status. + +**Behavior matrix:** + +| Scenario | `hostApprovalVoice` | Voice allowed? | +|----------|---------------------|----------------| +| Host → pending member, voice disabled | True | Yes (new) | +| Host → approved member in support, voice disabled | False (`memberPending` = False) | No | +| Pending member → host, voice disabled | False (`senderRole` < GRAdmin) | No | +| Anyone outside support scope, voice disabled | False (`inApprovalPhase` = False) | No | +| Any sender, voice enabled | N/A (`groupFeatureMemberAllowed` = True) | Yes (existing) | + +**Version gating:** Old clients (< v17) don't have this exemption. On the sender side this is handled by the bot (4.3). On the recipient side: + +- Old recipient + voice-disabled group: recipient rejects the voice message (shows "Voice messages: received, prohibited") +- This is why the bot must check the member's version before sending voice + +### 4.3. Service.hs — version-aware voice captcha logic + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +#### 4.3.1. Add import + +Add `memberSupportVoiceVersion` to the `Protocol` import: + +```haskell +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) +``` + +#### 4.3.2. Add helper predicate + +Add a helper in the `directoryService` `where` block (same scope as `sendMemberCaptcha`, `sendVoiceCaptcha`, etc., where `opts` is in scope): + +```haskell +canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool +canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) +``` + +Logic: +- Voice captcha generator must be configured +- AND either the group allows voice for the bot/host (any client version works — old clients accept voice from permitted senders) OR the member's client supports v17 (exemption applies on receive side) + +Note: `groupFeatureUserAllowed` checks if the bot (group owner) is permitted to send voice. This is what the recipient's `prohibitedGroupContent` checks — it validates the *sender's* permission (`m` parameter = sender's GroupMember), not the recipient's. Using `groupFeatureMemberAllowed SGFVoice m gInfo` (joining member) would be wrong: it would incorrectly block voice captcha in groups with role-based voice settings (e.g., "admins only"). + +#### 4.3.3. Update `dePendingMember` hint text (line 572) + +Change from: + +```haskell +<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +to: + +```haskell +<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" +``` + +This hides the `/audio` hint when voice captcha cannot be delivered. + +#### 4.3.4. Update `dePendingMemberMsg` `/audio` handling (lines 644-649) + +When a member sends `/audio`, check `canSendVoiceCaptcha` before switching mode. If voice captcha is not possible, reply with an upgrade message: + +```haskell +| isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] +``` + +#### 4.3.5. Add message constant + +```haskell +voiceCaptchaUnavailable :: Text +voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." +``` + +#### 4.3.6. Update `dePendingMemberMsg` no-captcha `/audio` path (lines 640-642) + +Same check for the case when no pending captcha exists yet: + +```haskell +Nothing -> + if isAudioCmd && canSendVoiceCaptcha g m + then sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + else if isAudioCmd + then sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(Just ciId, MCText voiceCaptchaUnavailable)] + else let mode = CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode +``` + +### 4.4. Tests + +**File:** `tests/Bots/DirectoryTests.hs` + +Update existing audio captcha tests to cover: +1. Group with voice enabled + any client version: `/audio` works (existing behavior) +2. Group with voice disabled + member version >= 17: `/audio` works +3. Group with voice disabled + member version < 17: `/audio` shows unavailable message, hint is hidden + +### 4.5. Changes summary + +| File | Change | Lines affected | +|------|--------|----------------| +| `Protocol.hs` | Add v17 constant, bump `currentChatVersion` | ~4 lines added | +| `Internal.hs` | Exempt host voice during approval phase | ~6 lines modified/added | +| `Service.hs` | Version-aware voice captcha logic | ~15 lines modified/added | +| `DirectoryTests.hs` | Test coverage for version gating | TBD | diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 607839ed36..896dfbb747 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -334,13 +334,22 @@ quoteContent mc qmc ciFile_ qTextOrFile = if T.null qText then qFileName else qText prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature -prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent - | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports | isNothing scopeInfo && prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing where + hostApprovalVoice + | sent = userRole >= GRAdmin && sendApprovalPhase + | otherwise = memberCategory m == GCHostMember && hostApprovalPhase + hostApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport Nothing) -> memberStatus mem == GSMemPendingApproval + _ -> False + sendApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberStatus scopeMem == GSMemPendingApproval + _ -> False -- admins cannot send reports, non-admins cannot receive reports badReportUser | sent = userRole >= GRModerator diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3272bc0115..e7a52f5153 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -77,12 +77,13 @@ import Simplex.Messaging.Version hiding (version) -- 14 - support sending and receiving group join rejection (2025-02-24) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 16 +currentChatVersion = VersionChat 17 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -145,6 +146,10 @@ groupKnockingVersion = VersionChat 15 shortLinkDataVersion :: VersionChat shortLinkDataVersion = VersionChat 16 +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 1b881bd446..9fb0f40928 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1205,10 +1205,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 6201c8b2e8..c5c110159a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4741,14 +4741,6 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE rcv_files - SET to_receive = 1, user_approved_relays = ?, updated_at = ? - WHERE file_id = ? - -Plan: -SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE remote_controllers SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 284a069bab..852e4cf4c4 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -73,6 +73,8 @@ directoryServiceTests = do it "should ask member to pass captcha screen" testCapthaScreening it "should send voice captcha on /audio command" testVoiceCaptchaScreening it "should retry with voice captcha after switching to audio mode" testVoiceCaptchaRetry + it "should send voice captcha when voice disabled but client supports v17" testVoiceCaptchaVoiceDisabled + it "should show unavailable message for old client in voice-disabled group" testVoiceCaptchaOldClient it "should reject member after too many captcha attempts" testCaptchaTooManyAttempts it "should respond to unknown command during captcha" testCaptchaUnknownCommand describe "store log" $ do @@ -1369,6 +1371,139 @@ testVoiceCaptchaRetry ps@TestParams {tmpPath} = do cath <#. "#privacy (support) 'SimpleX Directory'> sends file " cath <##. "use /fr 2" +testVoiceCaptchaVoiceDisabled :: HasCallStack => TestParams -> IO () +testVoiceCaptchaVoiceDisabled ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_vdisabled.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (new client, supports v17 exemption) joins, /audio hint shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + cath <## "Send /audio to receive a voice captcha." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- voice captcha works despite voice being disabled (v17 host approval exemption) + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + +testVoiceCaptchaOldClient :: HasCallStack => TestParams -> IO () +testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_oldclient.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (old client, max version < v17) joins, /audio hint NOT shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- /audio unavailable: old client can't receive voice in voice-disabled group + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath /audio" + cath <## " Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." + -- text captcha still works + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () withDirectoryServiceVoiceCaptcha ps voiceScript test = do dsLink <- diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2332fa429c..d607d1f208 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-16\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -270,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 26e15221f6181a156fc4b27b96cbd67dc13f89ff Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:28:39 +0000 Subject: [PATCH 6/7] directory-service: fix slow postgresql queries (#6639) * add analysis * implement p1.1 and p1.2 * Update apps/simplex-directory-service/src/Directory/Service.hs Co-authored-by: Evgeny * update plans * remove plans --------- Co-authored-by: Evgeny --- .../src/Directory/Service.hs | 4 ++-- src/Simplex/Chat/Library/Commands.hs | 4 ++-- src/Simplex/Chat/Library/Internal.hs | 8 +++----- src/Simplex/Chat/Library/Subscriber.hs | 8 ++++---- src/Simplex/Chat/Store/Groups.hs | 15 --------------- .../Store/SQLite/Migrations/agent_query_plans.txt | 4 ++++ .../Store/SQLite/Migrations/chat_query_plans.txt | 8 -------- 7 files changed, 15 insertions(+), 36 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 6666de7587..3b8391ada0 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -708,8 +708,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do - sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + sendComposedMessages cc (SRDirect cId) [msg, approveCmd] deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0cfcb9ab09..e5e8d60ad7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3478,8 +3478,8 @@ processChatCommand vr nm = \case pure (groupId, groupMemberId) sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 896dfbb747..00fd2f18d9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -933,11 +933,9 @@ acceptGroupJoinRequestAsync incognitoProfile = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted - ((groupMemberId, memberId), currentMemCount) <- withStore $ \db -> - liftM2 - (,) - (createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus) - (liftIO $ getGroupCurrentMembersCount db user gInfo) + (groupMemberId, memberId) <- withStore $ \db -> + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e10bf2a081..7fcc07640d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -700,8 +700,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, @@ -942,8 +942,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts && any aChatMsgHasReceipt aMsgs diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a54a3a6913..7ae47adab5 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -68,7 +68,6 @@ module Simplex.Chat.Store.Groups getGroupModerators, getGroupRelays, getGroupMembersForExpiration, - getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, @@ -1100,20 +1099,6 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do - statuses :: [GroupMemberStatus] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT member_status - FROM group_members - WHERE group_id = ? AND user_id = ? - |] - (groupId, userId) - pure $ length $ filter memberCurrent' statuses - getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 9fb0f40928..ebdf7e1f5c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1197,6 +1197,10 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ? +Plan: +SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index c5c110159a..ac54043194 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1430,14 +1430,6 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT member_status - FROM group_members - WHERE group_id = ? AND user_id = ? - -Plan: -SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) - Query: SELECT r.file_id FROM rcv_files r From 0946f50b6a3b881dfaa4b699cb5a85499c42332d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:58:16 +0000 Subject: [PATCH 7/7] ios: product specification (#6633) --- apps/ios/CODE.md | 219 +++++++ apps/ios/Shared/AppDelegate.swift | 1 + apps/ios/Shared/ContentView.swift | 10 + apps/ios/Shared/Model/AppAPITypes.swift | 6 + apps/ios/Shared/Model/BGManager.swift | 6 + apps/ios/Shared/Model/ChatModel.swift | 20 + apps/ios/Shared/Model/NtfManager.swift | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 19 + apps/ios/Shared/SimpleXApp.swift | 3 + apps/ios/Shared/Theme/Theme.swift | 3 + apps/ios/Shared/Theme/ThemeManager.swift | 13 + .../Shared/Views/Call/ActiveCallView.swift | 5 + .../Shared/Views/Call/CallController.swift | 10 + apps/ios/Shared/Views/Call/WebRTCClient.swift | 12 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 + .../Chat/ChatItem/AnimatedImageView.swift | 2 + .../Views/Chat/ChatItem/CICallItemView.swift | 1 + .../Chat/ChatItem/CIChatFeatureView.swift | 2 + .../Views/Chat/ChatItem/CIEventView.swift | 2 + .../ChatItem/CIFeaturePreferenceView.swift | 2 + .../Views/Chat/ChatItem/CIFileView.swift | 2 + .../Chat/ChatItem/CIGroupInvitationView.swift | 2 + .../Views/Chat/ChatItem/CIImageView.swift | 2 + .../Chat/ChatItem/CIInvalidJSONView.swift | 2 + .../Views/Chat/ChatItem/CILinkView.swift | 2 + .../ChatItem/CIMemberCreatedContactView.swift | 2 + .../Views/Chat/ChatItem/CIMetaView.swift | 2 + .../Chat/ChatItem/CIRcvDecryptionError.swift | 2 + .../Views/Chat/ChatItem/CIVideoView.swift | 2 + .../Views/Chat/ChatItem/CIVoiceView.swift | 2 + .../Views/Chat/ChatItem/DeletedItemView.swift | 2 + .../Views/Chat/ChatItem/EmojiItemView.swift | 2 + .../Chat/ChatItem/FramedCIVoiceView.swift | 2 + .../Views/Chat/ChatItem/FramedItemView.swift | 2 + .../Chat/ChatItem/FullScreenMediaView.swift | 2 + .../ChatItem/IntegrityErrorItemView.swift | 2 + .../Chat/ChatItem/MarkedDeletedItemView.swift | 2 + .../Views/Chat/ChatItem/MsgContentView.swift | 2 + .../Shared/Views/Chat/ChatItemInfoView.swift | 1 + apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 19 + .../Chat/ComposeMessage/ComposeView.swift | 27 + .../Chat/ComposeMessage/SendMessageView.swift | 1 + .../Chat/Group/AddGroupMembersView.swift | 1 + .../Views/Chat/Group/GroupChatInfoView.swift | 2 + .../Views/Chat/Group/GroupLinkView.swift | 1 + .../Chat/Group/GroupMemberInfoView.swift | 1 + .../Views/ChatList/ChatListNavLink.swift | 10 + .../Shared/Views/ChatList/ChatListView.swift | 12 + .../Views/ChatList/ChatPreviewView.swift | 1 + .../Shared/Views/ChatList/TagListView.swift | 1 + .../Shared/Views/ChatList/UserPicker.swift | 1 + .../Database/DatabaseEncryptionView.swift | 2 + .../Views/Database/DatabaseErrorView.swift | 1 + .../Shared/Views/Database/DatabaseView.swift | 2 + .../Database/MigrateToAppGroupView.swift | 1 + .../Views/LocalAuth/LocalAuthView.swift | 1 + .../Views/LocalAuth/PasscodeEntry.swift | 1 + .../Shared/Views/LocalAuth/PasscodeView.swift | 1 + .../Views/LocalAuth/SetAppPasscodeView.swift | 1 + .../Views/Migration/MigrateFromDevice.swift | 1 + .../Views/Migration/MigrateToDevice.swift | 1 + .../Shared/Views/NewChat/NewChatView.swift | 3 + apps/ios/Shared/Views/NewChat/QRCode.swift | 1 + .../Onboarding/AddressCreationCard.swift | 1 + .../Onboarding/ChooseServerOperators.swift | 1 + .../Views/Onboarding/CreateProfile.swift | 1 + .../Onboarding/CreateSimpleXAddress.swift | 1 + .../Shared/Views/Onboarding/HowItWorks.swift | 1 + .../Views/Onboarding/OnboardingView.swift | 3 + .../Onboarding/SetNotificationsMode.swift | 1 + .../Shared/Views/Onboarding/SimpleXInfo.swift | 1 + .../Views/Onboarding/WhatsNewView.swift | 1 + .../UserSettings/AppearanceSettings.swift | 7 + .../AdvancedNetworkSettings.swift | 1 + .../NetworkAndServers/ConditionsWebView.swift | 1 + .../NetworkAndServers/NetworkAndServers.swift | 1 + .../NetworkAndServers/NewServerView.swift | 1 + .../NetworkAndServers/OperatorView.swift | 1 + .../ProtocolServerView.swift | 1 + .../ProtocolServersView.swift | 1 + .../ScanProtocolServer.swift | 1 + .../Views/UserSettings/SettingsView.swift | 1 + .../Views/UserSettings/UserProfilesView.swift | 1 + .../ios/SimpleX NSE/NotificationService.swift | 12 + apps/ios/SimpleXChat/API.swift | 1 + apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/CallTypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 5 + apps/ios/SimpleXChat/CryptoFile.swift | 5 + apps/ios/SimpleXChat/FileUtils.swift | 19 + apps/ios/SimpleXChat/Notifications.swift | 10 + .../Theme/ChatWallpaperTypes.swift | 2 + apps/ios/SimpleXChat/Theme/ThemeTypes.swift | 9 + apps/ios/product/README.md | 258 ++++++++ apps/ios/product/concepts.md | 83 +++ apps/ios/product/flows/calling.md | 179 ++++++ apps/ios/product/flows/connection.md | 159 +++++ apps/ios/product/flows/file-transfer.md | 209 ++++++ apps/ios/product/flows/group-lifecycle.md | 216 +++++++ apps/ios/product/flows/messaging.md | 178 ++++++ apps/ios/product/flows/onboarding.md | 239 +++++++ apps/ios/product/gaps.md | 61 ++ apps/ios/product/glossary.md | 235 +++++++ apps/ios/product/rules.md | 119 ++++ apps/ios/product/views/call.md | 122 ++++ apps/ios/product/views/chat-list.md | 113 ++++ apps/ios/product/views/chat.md | 165 +++++ apps/ios/product/views/contact-info.md | 154 +++++ apps/ios/product/views/group-info.md | 147 +++++ apps/ios/product/views/new-chat.md | 94 +++ apps/ios/product/views/onboarding.md | 147 +++++ apps/ios/product/views/settings.md | 172 +++++ apps/ios/product/views/user-profiles.md | 137 ++++ apps/ios/spec/README.md | 74 +++ apps/ios/spec/api.md | 600 ++++++++++++++++++ apps/ios/spec/architecture.md | 298 +++++++++ apps/ios/spec/client/chat-list.md | 280 ++++++++ apps/ios/spec/client/chat-view.md | 331 ++++++++++ apps/ios/spec/client/compose.md | 355 +++++++++++ apps/ios/spec/client/navigation.md | 312 +++++++++ apps/ios/spec/database.md | 298 +++++++++ apps/ios/spec/impact.md | 114 ++++ apps/ios/spec/services/calls.md | 383 +++++++++++ apps/ios/spec/services/files.md | 368 +++++++++++ apps/ios/spec/services/notifications.md | 390 ++++++++++++ apps/ios/spec/services/theme.md | 383 +++++++++++ apps/ios/spec/state.md | 463 ++++++++++++++ 128 files changed, 8418 insertions(+) create mode 100644 apps/ios/CODE.md create mode 100644 apps/ios/product/README.md create mode 100644 apps/ios/product/concepts.md create mode 100644 apps/ios/product/flows/calling.md create mode 100644 apps/ios/product/flows/connection.md create mode 100644 apps/ios/product/flows/file-transfer.md create mode 100644 apps/ios/product/flows/group-lifecycle.md create mode 100644 apps/ios/product/flows/messaging.md create mode 100644 apps/ios/product/flows/onboarding.md create mode 100644 apps/ios/product/gaps.md create mode 100644 apps/ios/product/glossary.md create mode 100644 apps/ios/product/rules.md create mode 100644 apps/ios/product/views/call.md create mode 100644 apps/ios/product/views/chat-list.md create mode 100644 apps/ios/product/views/chat.md create mode 100644 apps/ios/product/views/contact-info.md create mode 100644 apps/ios/product/views/group-info.md create mode 100644 apps/ios/product/views/new-chat.md create mode 100644 apps/ios/product/views/onboarding.md create mode 100644 apps/ios/product/views/settings.md create mode 100644 apps/ios/product/views/user-profiles.md create mode 100644 apps/ios/spec/README.md create mode 100644 apps/ios/spec/api.md create mode 100644 apps/ios/spec/architecture.md create mode 100644 apps/ios/spec/client/chat-list.md create mode 100644 apps/ios/spec/client/chat-view.md create mode 100644 apps/ios/spec/client/compose.md create mode 100644 apps/ios/spec/client/navigation.md create mode 100644 apps/ios/spec/database.md create mode 100644 apps/ios/spec/impact.md create mode 100644 apps/ios/spec/services/calls.md create mode 100644 apps/ios/spec/services/files.md create mode 100644 apps/ios/spec/services/notifications.md create mode 100644 apps/ios/spec/services/theme.md create mode 100644 apps/ios/spec/state.md diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md new file mode 100644 index 0000000000..adb5ef8c42 --- /dev/null +++ b/apps/ios/CODE.md @@ -0,0 +1,219 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Document Map + +### iOS Swift Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md | +| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | +| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | +| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | +| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md | +| Shared/Views/Call/CallController.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/Call/WebRTCClient.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/UserSettings/SettingsView.swift | spec/client/navigation.md | product/views/settings.md | +| Shared/Views/UserSettings/AppearanceSettings.swift | spec/services/theme.md | product/views/settings.md | +| Shared/Views/UserSettings/NetworkAndServers/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/UserSettings/UserProfilesView.swift | spec/client/navigation.md | product/views/user-profiles.md | +| Shared/Views/Onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| Shared/Views/LocalAuth/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/Database/ | spec/database.md | product/views/settings.md | +| Shared/Views/Migration/ | spec/database.md | product/flows/onboarding.md | +| Shared/Model/ChatModel.swift | spec/state.md | product/concepts.md | +| Shared/Model/SimpleXAPI.swift | spec/api.md, spec/architecture.md | product/concepts.md | +| Shared/Model/AppAPITypes.swift | spec/api.md | product/concepts.md | +| Shared/Model/NtfManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Model/BGManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Theme/ThemeManager.swift | spec/services/theme.md | product/views/settings.md | +| SimpleXChat/ChatTypes.swift | spec/state.md, spec/api.md | product/glossary.md | +| SimpleXChat/APITypes.swift | spec/api.md | product/concepts.md | +| SimpleXChat/CallTypes.swift | spec/services/calls.md | product/flows/calling.md | +| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md | +| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md | +| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 3f6998c9ec..0a401f9bf3 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 30/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UIKit diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 7adf7a0435..a6896fa51d 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/client/navigation.md import SwiftUI import Intents @@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable { } } +// Spec: spec/client/navigation.md#ContentView struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + // Spec: spec/client/navigation.md#AppSheetState @ObservedObject var appSheetState = AppSheetState.shared @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme @EnvironmentObject var sceneDelegate: SceneDelegate + // Spec: spec/client/navigation.md#contentAccessAuthenticationExtended var contentAccessAuthenticationExtended: Bool @Environment(\.scenePhase) var scenePhase @@ -161,6 +165,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#contentView @ViewBuilder private func contentView() -> some View { if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) @@ -176,6 +181,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callView @ViewBuilder private func callView(_ call: Call) -> some View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) @@ -193,6 +199,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callBanner private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) @@ -227,6 +234,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#lockButton private func lockButton() -> some View { Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } @@ -339,6 +347,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#unlockedRecently private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 @@ -426,6 +435,7 @@ struct ContentView: View { ) } + // Spec: spec/client/navigation.md#connectViaUrl func connectViaUrl() { let m = ChatModel.shared if let url = m.appOpenUrl { diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e213f1c076..f82a2fd2eb 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -5,11 +5,13 @@ // Created by EP on 01/05/2025. // Copyright © 2025 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import SimpleXChat import SwiftUI // some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +// Spec: spec/api.md#ChatCommand enum ChatCommand: ChatCmdProtocol { case showActiveUser case createActiveUser(profile: Profile?, pastTimestamp: Bool) @@ -643,6 +645,7 @@ enum ChatCommand: ChatCmdProtocol { } // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +// Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { case activeUser(user: User) case usersList(users: [UserInfo]) @@ -764,6 +767,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse1 enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) @@ -903,6 +907,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse2 enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) @@ -1046,6 +1051,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatEvent enum ChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 25eab6c69e..aa4dfa24f8 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import BackgroundTasks @@ -25,6 +26,7 @@ private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes private let maxTimerCount = 9 +// Spec: spec/services/notifications.md#BGManager class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? @@ -32,6 +34,7 @@ class BGManager { var completed = true var timerCount = 0 + // Spec: spec/services/notifications.md#register func register() { logger.debug("BGManager.register") BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in @@ -39,6 +42,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#schedule func schedule(interval: TimeInterval? = nil) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.schedule: disabled") @@ -66,6 +70,7 @@ class BGManager { Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval } + // Spec: spec/services/notifications.md#handleRefresh private func handleRefresh(_ task: BGAppRefreshTask) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.handleRefresh: disabled") @@ -103,6 +108,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#receiveMessages-BG func receiveMessages(_ completeReceiving: @escaping (String) -> Void) { if (!self.completed) { logger.debug("BGManager.receiveMessages: in progress, exiting") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f1f4e686bd..46e9df1ef8 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 22/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md import Foundation import Combine @@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { } // analogue for SecondaryContextFilter in Kotlin +// Spec: spec/state.md#SecondaryItemsModelFilter enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) @@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter { } // analogue for ChatsContext in Kotlin +// Spec: spec/state.md#ItemsModel class ItemsModel: ObservableObject { static let shared = ItemsModel(secondaryIMFilter: nil) public var secondaryIMFilter: SecondaryItemsModelFilter? @@ -103,12 +106,14 @@ class ItemsModel: ObservableObject { .store(in: &bag) } + // Spec: spec/state.md#loadSecondaryChat static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { let im = ItemsModel(secondaryIMFilter: chatFilter) ChatModel.shared.secondaryIM = im im.loadOpenChat(chatId, willNavigate: willNavigate) } + // Spec: spec/state.md#loadOpenChat func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -134,6 +139,7 @@ class ItemsModel: ObservableObject { } } + // Spec: spec/state.md#loadOpenChatNoWait func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -179,6 +185,7 @@ class PreloadState { } } +// Spec: spec/state.md#ChatTagsModel class ChatTagsModel: ObservableObject { static let shared = ChatTagsModel() @@ -326,6 +333,7 @@ class ConnectProgressManager: ObservableObject { } } +// Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -383,6 +391,7 @@ final class ChatModel: ObservableObject { @Published var showCallView = false @Published var activeCallViewIsCollapsed = false // remote desktop + // Spec: spec/architecture.md#remoteCtrlSession @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? @@ -423,6 +432,7 @@ final class ChatModel: ObservableObject { userAddress?.shortLinkDataSet ?? true } + // Spec: spec/state.md#getUser func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -433,6 +443,7 @@ final class ChatModel: ObservableObject { users.firstIndex { $0.user.userId == user.userId } } + // Spec: spec/state.md#updateUser func updateUser(_ user: User) { if let i = getUserIndex(user) { users[i].user = user @@ -442,6 +453,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#removeUser func removeUser(_ user: User) { if let i = getUserIndex(user) { users.remove(at: i) @@ -452,6 +464,7 @@ final class ChatModel: ObservableObject { chats.first(where: { $0.id == id }) != nil } + // Spec: spec/state.md#getChat func getChat(_ id: String) -> Chat? { chats.first(where: { $0.id == id }) } @@ -506,6 +519,7 @@ final class ChatModel: ObservableObject { chats.firstIndex(where: { $0.id == id }) } + // Spec: spec/state.md#addChat func addChat(_ chat: Chat) { if chatId == nil { withAnimation { addChat_(chat, at: 0) } @@ -519,6 +533,7 @@ final class ChatModel: ObservableObject { chats.insert(chat, at: position) } + // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { @@ -570,6 +585,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#replaceChat func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { chats[i] = chat @@ -1054,6 +1070,7 @@ final class ChatModel: ObservableObject { NtfManager.shared.changeNtfBadgeCount(by: by) } + // Spec: spec/state.md#totalUnreadCountForAllUsers func totalUnreadCountForAllUsers() -> Int { var unread: Int = 0 for chat in chats { @@ -1153,6 +1170,7 @@ final class ChatModel: ObservableObject { return (prevMember, memberIds.count) } + // Spec: spec/state.md#popChat func popChat(_ id: String) { if let i = getChatIndex(id) { // no animation here, for it not to look like it just moved when leaving the chat @@ -1176,6 +1194,7 @@ final class ChatModel: ObservableObject { showingInvitation?.connChatUsed = true } + // Spec: spec/state.md#removeChat func removeChat(_ id: String) { withAnimation { if let i = getChatIndex(id) { @@ -1248,6 +1267,7 @@ struct NTFContactRequest { var chatId: String } +// Spec: spec/state.md#Chat final class Chat: ObservableObject, Identifiable, ChatLike { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 79f4ef2f09..c6c6e88d8c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ enum NtfCallAction { case reject } +// Spec: spec/services/notifications.md#NtfManager class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { static let shared = NtfManager() @@ -48,6 +50,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { handler() } + // Spec: spec/services/notifications.md#processNotificationResponse func processNotificationResponse(_ ntfResponse: UNNotificationResponse) { let chatModel = ChatModel.shared let content = ntfResponse.notification.request.content @@ -149,6 +152,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { return false } + // Spec: spec/services/notifications.md#registerCategories func registerCategories() { logger.debug("NtfManager.registerCategories") UNUserNotificationCenter.current().setNotificationCategories([ @@ -207,6 +211,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { ]) } + // Spec: spec/services/notifications.md#requestAuthorization func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) { logger.debug("NtfManager.requestAuthorization") let center = UNUserNotificationCenter.current() @@ -230,6 +235,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyContactRequest func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") addNotification(createContactRequestNtf(user, contactRequest, 0)) @@ -240,6 +246,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createContactConnectedNtf(user, contact, 0)) } + // Spec: spec/services/notifications.md#notifyMessageReceived func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled(chatItem: cItem) { @@ -247,16 +254,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyCallInvitation func notifyCallInvitation(_ invitation: RcvCallInvitation) { logger.debug("NtfManager.notifyCallInvitation") addNotification(createCallInvitationNtf(invitation, 0)) } + // Spec: spec/services/notifications.md#setNtfBadgeCount func setNtfBadgeCount(_ count: Int) { UIApplication.shared.applicationIconBadgeNumber = count ntfBadgeCountGroupDefault.set(count) } + // Spec: spec/services/notifications.md#changeNtfBadgeCount func changeNtfBadgeCount(by count: Int = 1) { setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 46ee753438..7eb2de11ab 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md | spec/architecture.md import Foundation import UIKit @@ -49,6 +50,7 @@ enum TerminalItem: Identifiable { } } +// Spec: spec/architecture.md#beginBGTask func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -86,12 +88,14 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } +// Spec: spec/api.md#chatSendCmdSync @inline(__always) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdSync func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") @@ -112,12 +116,14 @@ func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = tru return resp } +// Spec: spec/api.md#chatSendCmd @inline(__always) func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdWithRetry func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) if inProgress == nil || inProgress?.boxedValue == true, @@ -210,6 +216,7 @@ func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer)) } +// Spec: spec/api.md#chatApiSendCmd @inline(__always) func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in @@ -226,6 +233,7 @@ func apiResult(_ res: APIResult) throws -> R { } } +// Spec: spec/api.md#chatRecvMsg func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in @@ -346,6 +354,7 @@ func apiStopChat() async throws { } } +// Spec: spec/architecture.md#apiActivateChat func apiActivateChat() { chatReopenStore() do { @@ -355,6 +364,7 @@ func apiActivateChat() { } } +// Spec: spec/architecture.md#apiSuspendChat func apiSuspendChat(timeoutMicroseconds: Int) { do { try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) @@ -363,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { } } +// Spec: spec/services/files.md#apiSetAppFilePaths func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } throw r.unexpected } +// Spec: spec/services/files.md#apiSetEncryptLocalFiles func apiSetEncryptLocalFiles(_ enable: Bool) throws { try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } @@ -1455,6 +1467,7 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF } } +// Spec: spec/services/files.md#receiveFile func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { await receiveFiles( user: user, @@ -1573,6 +1586,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } +// Spec: spec/services/files.md#cancelFile func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1595,12 +1609,14 @@ func setLocalDeviceName(_ displayName: String) throws { try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } +// Spec: spec/architecture.md#connectRemoteCtrl func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r.unexpected } +// Spec: spec/architecture.md#findKnownRemoteCtrl func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } @@ -2078,6 +2094,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } +// Spec: spec/architecture.md#startChat func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared @@ -2199,6 +2216,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { } } +// Spec: spec/architecture.md#ChatReceiver class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -2244,6 +2262,7 @@ class ChatReceiver { } } +// Spec: spec/api.md#processReceivedMsg func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared logger.debug("processReceivedMsg: \(res.responseType)") diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e1a6bb61e8..1e9a97c31b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/architecture.md import SwiftUI import OSLog @@ -12,6 +13,7 @@ import SimpleXChat let logger = Logger() @main +// Spec: spec/architecture.md#SimpleXApp struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @@ -60,6 +62,7 @@ struct SimpleXApp: App { } } } +// Spec: spec/architecture.md#scenePhaseHandling .onChange(of: scenePhase) { phase in logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") AppSheetState.shared.scenePhaseActive = phase == .active diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index 3bd8f00c25..1f98b23a1d 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#CurrentColors var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } } @@ -17,6 +18,7 @@ var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 } func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight } +// Spec: spec/services/theme.md#AppTheme class AppTheme: ObservableObject, Equatable { static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper) @@ -89,6 +91,7 @@ struct ThemedBackground: ViewModifier { } } +// Spec: spec/services/theme.md#systemInDarkThemeCurrently var systemInDarkThemeCurrently: Bool { return UITraitCollection.current.userInterfaceStyle == .dark } diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift index 4166619d04..b9a35163cf 100644 --- a/apps/ios/Shared/Theme/ThemeManager.swift +++ b/apps/ios/Shared/Theme/ThemeManager.swift @@ -5,12 +5,15 @@ // Created by Avently on 03.06.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#ThemeManager class ThemeManager { + // Spec: spec/services/theme.md#ActiveTheme struct ActiveTheme: Equatable { let name: String let base: DefaultTheme @@ -41,6 +44,7 @@ class ThemeManager { } } + // Spec: spec/services/theme.md#defaultActiveTheme static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? { let nonSystemThemeName = nonSystemThemeName() let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName] @@ -56,6 +60,7 @@ class ThemeManager { return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil)) } + // Spec: spec/services/theme.md#currentColors static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme { let themeName = currentThemeDefault.get() let nonSystemThemeName = nonSystemThemeName() @@ -96,6 +101,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#currentThemeOverridesForExport static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides { let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get()) let wType = current.wallpaper.type @@ -114,6 +120,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#applyTheme static func applyTheme(_ theme: String) { currentThemeDefault.set(theme) CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) @@ -125,6 +132,7 @@ class ThemeManager { // applyNavigationBarColors(CurrentColors.toAppTheme()) } + // Spec: spec/services/theme.md#adjustWindowStyle static func adjustWindowStyle() { let style = switch currentThemeDefault.get() { case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light @@ -161,6 +169,7 @@ class ThemeManager { AppTheme.shared.updateFromCurrentColors() } + // Spec: spec/services/theme.md#saveAndApplyThemeColor static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -178,6 +187,7 @@ class ThemeManager { pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex()) } + // Spec: spec/services/theme.md#saveAndApplyWallpaper static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -253,6 +263,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#saveAndApplyThemeOverrides static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let wallpaper = theme.wallpaper?.importFromString() let nonSystemThemeName = theme.base.themeName @@ -273,6 +284,7 @@ class ThemeManager { applyTheme(nonSystemThemeName) } + // Spec: spec/services/theme.md#resetAllThemeColors static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = nonSystemThemeName() let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault @@ -295,6 +307,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#removeTheme static func removeTheme(_ themeId: String?) { var themes = themeOverridesDefault.get().map { $0 } themes.removeAll(where: { $0.themeId == themeId }) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ab7a47b944..754bcb2715 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import SwiftUI import WebKit import SimpleXChat import AVFoundation +// Spec: spec/services/calls.md#ActiveCallView struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -282,6 +284,7 @@ struct ActiveCallView: View { } } +// Spec: spec/services/calls.md#ActiveCallOverlay struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call @@ -350,6 +353,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#audioCallInfoView private func audioCallInfoView(_ call: Call) -> some View { VStack { Text(call.contact.chatViewName) @@ -399,6 +403,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#endCallButton private func endCallButton() -> some View { let cc = CallController.shared return callButton("phone.down.fill", .red, padding: 10) { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 1f28180e87..9df0c2f0b7 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 21/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import CallKit @@ -14,6 +15,7 @@ import AVFoundation import SimpleXChat import WebRTC +// Spec: spec/services/calls.md#CallController class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { static let shared = CallController() static let isInChina = SKStorefront().countryCode == "CHN" @@ -49,6 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController.providerDidReset") } + // Spec: spec/services/calls.md#CXStartCallAction func provider(_ provider: CXProvider, perform action: CXStartCallAction) { logger.debug("CallController.provider CXStartCallAction") if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) { @@ -59,6 +62,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXAnswerCallAction func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { logger.debug("CallController.provider CXAnswerCallAction") Task { @@ -88,6 +92,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXEndCallAction func provider(_ provider: CXProvider, perform action: CXEndCallAction) { logger.debug("CallController.provider CXEndCallAction") // Should be nil here if connection was in connected state @@ -103,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXSetMutedCallAction func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) { action.fulfill() @@ -192,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)") } + // Spec: spec/services/calls.md#pushRegistryDidReceive func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { logger.debug("CallController: did receive push with type \(type.rawValue)") if type != .voIP { @@ -276,6 +283,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse reportExpiredCall(update: update, completion) } + // Spec: spec/services/calls.md#reportNewIncomingCall func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))") if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -316,6 +324,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#reportOutgoingCall func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { logger.debug("CallController: reporting outgoing call connected") if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -422,6 +431,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse provider.configuration = conf } + // Spec: spec/services/calls.md#hasActiveCalls func hasActiveCalls() -> Bool { controller.callObserver.calls.count > 0 } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index db7910836e..2ce04e4b80 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -2,12 +2,14 @@ // Created by Avently on 09.02.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import WebRTC import LZString import SwiftUI import SimpleXChat +// Spec: spec/services/calls.md#WebRTCClient final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDelegate, RTCFrameDecryptorDelegate { private static let factory: RTCPeerConnectionFactory = { RTCInitializeSSL() @@ -87,6 +89,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), ] + // Spec: spec/services/calls.md#initializeCall func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) connection.delegate = self @@ -132,6 +135,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg ) } + // Spec: spec/services/calls.md#createPeerConnection func createPeerConnection(_ iceServers: [WebRTC.RTCIceServer], _ relay: Bool?) -> RTCPeerConnection { let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue]) @@ -157,6 +161,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return config } + // Spec: spec/services/calls.md#addIceCandidates func addIceCandidates(_ connection: RTCPeerConnection, _ remoteIceCandidates: [RTCIceCandidate]) { remoteIceCandidates.forEach { candidate in connection.add(candidate.toWebRTCCandidate()) { error in @@ -167,6 +172,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendCallCommand func sendCallCommand(command: WCallCommand) async { var resp: WCallResponse? = nil let pc = activeCall?.connection @@ -295,6 +301,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( corrId: nil, @@ -353,6 +360,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#enableMedia @MainActor func enableMedia(_ source: CallMediaSource, _ enable: Bool) { logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)") @@ -411,6 +419,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg localRendererAspectRatio.wrappedValue = size.width / size.height } + // Spec: spec/services/calls.md#setupLocalTracks func setupLocalTracks(_ incomingCall: Bool, _ call: Call) { let pc = call.connection let transceivers = call.connection.transceivers @@ -490,6 +499,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } // Should be called after local description set + // Spec: spec/services/calls.md#setupEncryptionForLocalTracks func setupEncryptionForLocalTracks(_ call: Call) { if let encryptor = call.frameEncryptor { call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) } @@ -567,6 +577,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#startCaptureLocalVideo func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) { #if targetEnvironment(simulator) guard @@ -630,6 +641,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return (localCamera, localVideoTrack) } + // Spec: spec/services/calls.md#endCall func endCall() { if #available(iOS 16.0, *) { _endCall() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ad82af05e2..c17d8e23a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 05/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI @preconcurrency import SimpleXChat @@ -88,6 +89,7 @@ enum SendReceipts: Identifiable, Hashable { } } +// Spec: spec/client/chat-view.md#ChatInfoView struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift index 30f5e7a589..93ffb9f042 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift @@ -2,10 +2,12 @@ // Created by Avently on 19.12.2022. // Copyright (c) 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import UIKit import SwiftUI +// Spec: spec/client/chat-view.md#AnimatedImageView class AnimatedImageView: UIView { var image: UIImage? = nil var imageView: UIImageView? = nil diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 0283e9c07e..e5f3c05eed 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index b2b4441646..5521470d07 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIChatFeatureView struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 1375b87a5a..49a086d45a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 20.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIEventView struct CIEventView: View { var eventText: Text diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 67f7b69e2c..dcd6ea579c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFeaturePreferenceView struct CIFeaturePreferenceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 1b9376b5db..639de1dbc9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFileView struct CIFileView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 3fcf578875..ddb58fdfd1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 15.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIGroupInvitationView struct CIGroupInvitationView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d1f49f635a..8b5172eccf 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 12/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIImageView struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 5e9fa691de..80cccbf907 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 29.12.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIInvalidJSONView struct CIInvalidJSONView: View { @EnvironmentObject var theme: AppTheme var json: Data? diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index f07e90b953..a09518ffdb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -5,10 +5,12 @@ // Created by Ian Davies on 07/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CILinkView struct CILinkView: View { @EnvironmentObject var theme: AppTheme let linkPreview: LinkPreview diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index 2898a318a9..4719c3dcdc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -5,10 +5,12 @@ // Created by spaced4ndy on 19.09.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMemberCreatedContactView struct CIMemberCreatedContactView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index fc73778239..e3bc654ac9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 11/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMetaView struct CIMetaView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 3201332c1e..ec23dc15a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 15/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." +// Spec: spec/client/chat-view.md#CIRcvDecryptionError struct CIRcvDecryptionError: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index eacbe9360a..80bea997d3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -5,12 +5,14 @@ // Created by Avently on 30/03/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import AVKit import SimpleXChat import Combine +// Spec: spec/client/chat-view.md#CIVideoView struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 47aee2a586..820074542f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIVoiceView struct CIVoiceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index ed2340b6c4..fb5d36ab12 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#DeletedItemView struct DeletedItemView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 250d9d5636..04f36c97a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#EmojiItemView struct EmojiItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 0b6f249b9c..123f7289bb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedCIVoiceView struct FramedCIVoiceView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index c9c9952688..ec8bc852c0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedItemView struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index f243a83142..e14683684d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 08/10/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat import SwiftyGif import AVKit +// Spec: spec/client/chat-view.md#FullScreenMediaView struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel @State var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 47a30f6cf3..fdf3743aac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 28/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#IntegrityErrorItemView struct IntegrityErrorItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c6a5d0353c..953f4e8c82 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 30.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#MarkedDeletedItemView struct MarkedDeletedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 2a1b526893..852c8bbbac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 13/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -23,6 +24,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont. return res } +// Spec: spec/client/chat-view.md#MsgContentView struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 87c6ba92f8..3858d15252 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#ChatItemInfoView struct ChatItemInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5f48c18881..f72bf083f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -38,6 +38,7 @@ extension EnvironmentValues { } } +// Spec: spec/client/chat-view.md#ChatItemView struct ChatItemView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index dc1228fce8..057bf7f75f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -13,6 +14,7 @@ import Combine private let memberImageSize: CGFloat = 34 +// Spec: spec/client/chat-view.md#ChatView struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -70,6 +72,7 @@ struct ChatView: View { let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + // Spec: spec/client/chat-view.md#body var body: some View { if #available(iOS 16.0, *) { viewBody @@ -668,6 +671,7 @@ struct ChatView: View { .frame(width: 220) } + // Spec: spec/client/chat-view.md#initChatView private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -727,6 +731,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#scrollToItem private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -760,6 +765,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchToolbar private func searchToolbar() -> some View { let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" return HStack(spacing: 12) { @@ -797,6 +803,7 @@ struct ChatView: View { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } + // Spec: spec/client/chat-view.md#filtered private func filtered(_ reversedChatItems: Array) -> Array { reversedChatItems .enumerated() @@ -810,6 +817,7 @@ struct ChatView: View { .map { $0.element } } + // Spec: spec/client/chat-view.md#chatItemsList private func chatItemsList() -> some View { let cInfo = chat.chatInfo return GeometryReader { g in @@ -1083,6 +1091,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchTextChanged private func searchTextChanged(_ s: String) { Task { await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) @@ -1260,6 +1269,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#callButton private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -1397,6 +1407,7 @@ struct ChatView: View { )) } + // Spec: spec/client/chat-view.md#deletedSelectedMessages private func deletedSelectedMessages() async { await MainActor.run { withAnimation { @@ -1405,6 +1416,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#forwardSelectedMessages private func forwardSelectedMessages() { Task { do { @@ -1515,6 +1527,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#loadChatItems private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { if loadingMoreItems { return false } await MainActor.run { @@ -1555,6 +1568,7 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + // Spec: spec/client/chat-view.md#onChatItemsUpdated func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") @@ -1582,6 +1596,7 @@ struct ChatView: View { ) } + // Spec: spec/client/chat-view.md#ChatItemWithMenu private struct ChatItemWithMenu: View { @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @@ -2693,6 +2708,7 @@ struct ChatView: View { } } +// Spec: spec/client/chat-view.md#FloatingButtonModel class FloatingButtonModel: ObservableObject { @ObservedObject var im: ItemsModel @@ -2775,6 +2791,7 @@ private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } +// Spec: spec/client/chat-view.md#deleteMessages private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { let itemIds = deletingItems if itemIds.count > 0 { @@ -2878,6 +2895,7 @@ private func buildTheme() -> AppTheme { } } +// Spec: spec/client/chat-view.md#ReactionContextMenu struct ReactionContextMenu: View { @EnvironmentObject var m: ChatModel let groupInfo: GroupInfo @@ -3027,6 +3045,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +// Spec: spec/client/chat-view.md#ContentFilter enum ContentFilter: CaseIterable { case images case videos diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3745d0f0b8..2c462df9e4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1,3 +1,4 @@ +// Spec: spec/client/compose.md import SwiftUI import SimpleXChat @@ -6,6 +7,7 @@ import PhotosUI let MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -14,6 +16,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } +// Spec: spec/client/compose.md#ComposeContextItem enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) @@ -22,12 +25,14 @@ enum ComposeContextItem: Equatable { case reportedItem(chatItem: ChatItem, reason: ReportReason) } +// Spec: spec/client/compose.md#VoiceMessageRecordingState enum VoiceMessageRecordingState { case noRecording case recording case finished } +// Spec: spec/client/compose.md#LiveMessage struct LiveMessage { var chatItem: ChatItem var typedMsg: String @@ -36,6 +41,7 @@ struct LiveMessage { typealias MentionedMembers = [String: CIMention] +// Spec: spec/client/compose.md#ComposeState struct ComposeState { var message: String var parsedMessage: [FormattedText] @@ -256,6 +262,7 @@ struct ComposeState { } } +// Spec: spec/client/compose.md#chatItemPreview func chatItemPreview(chatItem: ChatItem) -> ComposePreview { switch chatItem.content.msgContent { case .text: @@ -276,6 +283,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { } } +// Spec: spec/client/compose.md#UploadContent enum UploadContent: Equatable { case simpleImage(image: UIImage) case animatedImage(image: UIImage) @@ -317,6 +325,7 @@ enum UploadContent: Equatable { } } +// Spec: spec/client/compose.md#ComposeView struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -356,6 +365,7 @@ struct ComposeView: View { @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @State private var updatingCompose = false + // Spec: spec/client/compose.md#body var body: some View { VStack(spacing: 0) { Divider() @@ -679,6 +689,7 @@ struct ComposeView: View { .padding(.horizontal, 12) } + // Spec: spec/client/compose.md#sendMessageView private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View { ZStack(alignment: .leading) { SendMessageView( @@ -878,6 +889,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#addMediaContent private func addMediaContent(_ content: UploadContent) async { if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { var newMedia: [(String, UploadContent?)] = [] @@ -906,6 +918,7 @@ struct ComposeView: View { getMaxFileSize(.xftp) } + // Spec: spec/client/compose.md#sendLiveMessage private func sendLiveMessage() async { let typedMsg = composeState.message let lm = composeState.liveMessage @@ -923,6 +936,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#updateLiveMessage private func updateLiveMessage() async { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { @@ -941,6 +955,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#liveMessageToSend private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil @@ -1087,6 +1102,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessage private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { @@ -1095,6 +1111,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessageAsync private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message @@ -1361,6 +1378,7 @@ struct ComposeView: View { await MainActor.run { composeState.inProgress = true } } + // Spec: spec/client/compose.md#startVoiceMessageRecording private func startVoiceMessageRecording() async { startingRecording = true let fileName = generateNewFileName("voice", "m4a") @@ -1401,6 +1419,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#finishVoiceMessageRecording private func finishVoiceMessageRecording() { audioRecorder?.stop() audioRecorder = nil @@ -1411,6 +1430,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#allowVoiceMessagesToContact private func allowVoiceMessagesToContact() { if case let .direct(contact) = chat.chatInfo { allowFeatureToContact(contact, .voice) @@ -1436,12 +1456,14 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#cancelVoiceMessageRecording private func cancelVoiceMessageRecording(_ fileName: String) { stopPlayback.toggle() audioRecorder?.stop() removeFile(fileName) } + // Spec: spec/client/compose.md#clearState private func clearState(live: Bool = false) { if live { composeState.inProgress = false @@ -1455,11 +1477,13 @@ struct ComposeView: View { startingRecording = false } + // Spec: spec/client/compose.md#saveCurrentDraft private func saveCurrentDraft() { chatModel.draft = composeState chatModel.draftChatId = chat.id } + // Spec: spec/client/compose.md#clearCurrentDraft private func clearCurrentDraft() { if chatModel.draftChatId == chat.id { chatModel.draft = nil @@ -1467,6 +1491,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#showLinkPreview private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg) @@ -1486,6 +1511,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#getMessageLinks private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) { guard let parsedMsg else { return (nil, false) } let simplexLink = parsedMsgHasSimplexLink(parsedMsg) @@ -1512,6 +1538,7 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) } + // Spec: spec/client/compose.md#loadLinkPreview private func loadLinkPreview(_ urlStr: String) { if pendingLinkUrl == urlStr, let url = URL(string: urlStr) { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 07cd61583b..713f462c27 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,6 +11,7 @@ import SimpleXChat private let liveMsgInterval: UInt64 = 3000_000000 +// Spec: spec/client/compose.md#SendMessageView struct SendMessageView: View { var placeholder: String? @Binding var composeState: ComposeState diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 3154f16f5b..6b18c0c5ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 22.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 96b5e2898a..257d5aac93 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 14.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +// Spec: spec/client/chat-view.md#GroupChatInfoView struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index bc1ac4ab65..43bc26e8f8 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.10.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 207c2170a3..17a05ffca4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 25.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 4937bca20e..381057db5b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -40,6 +40,7 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes { dynamicSizes[font] ?? defaultDynamicSizes } +// Spec: spec/client/chat-list.md#ChatListNavLink struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -90,6 +91,7 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } + // Spec: spec/client/chat-list.md#contactNavLink private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.isContactCard { @@ -211,6 +213,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#groupNavLink @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: @@ -295,6 +298,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#noteFolderNavLink private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, @@ -325,6 +329,7 @@ struct ChatListNavLink: View { .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) } + // Spec: spec/client/chat-list.md#markReadButton @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { @@ -344,6 +349,7 @@ struct ChatListNavLink: View { } + // Spec: spec/client/chat-list.md#toggleFavoriteButton @ViewBuilder private func toggleFavoriteButton() -> some View { if chat.chatInfo.chatSettings?.favorite == true { Button { @@ -362,6 +368,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#toggleNtfsButton @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { if let nextMode = chat.chatInfo.nextNtfMode { Button { @@ -382,6 +389,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#clearChatButton private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -483,6 +491,7 @@ struct ChatListNavLink: View { .tint(.red) } + // Spec: spec/client/chat-list.md#contactRequestNavLink private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .frameCompat(height: dynamicRowHeight) @@ -517,6 +526,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#contactConnectionNavLink private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) .frameCompat(height: dynamicRowHeight) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index efaba518a9..d84fa29c81 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-list.md import SwiftUI import SimpleXChat @@ -31,6 +32,7 @@ enum UserPickerSheet: Identifiable { } } +// Spec: spec/client/chat-list.md#PresetTag enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case groupReports = 0 case favorites = 1 @@ -46,6 +48,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { } } +// Spec: spec/client/chat-list.md#ActiveFilter enum ActiveFilter: Identifiable, Equatable { case presetTag(PresetTag) case userTag(ChatTag) @@ -135,6 +138,7 @@ struct UserPickerSheetView: View { } } +// Spec: spec/client/chat-list.md#ChatListView struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -160,6 +164,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + // Spec: spec/client/chat-list.md#body var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -445,6 +450,7 @@ struct ChatListView: View { } + // Spec: spec/client/chat-list.md#unreadBadge private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) @@ -464,11 +470,13 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#stopAudioPlayer func stopAudioPlayer() { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } @@ -511,6 +519,7 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#searchString func searchString() -> String { searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase } @@ -574,6 +583,7 @@ struct SubsStatusIndicator: View { } } +// Spec: spec/client/chat-list.md#ChatListSearchBar struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -875,6 +885,7 @@ struct TagsView: View { } } + // Spec: spec/client/chat-list.md#setActiveFilter private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -895,6 +906,7 @@ func chatStoppedIcon() -> some View { } } +// Spec: spec/client/chat-list.md#presetTagMatchesChat func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { case .groupReports: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index be2c456802..112e4099c0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#ChatPreviewView struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 79d122eabf..f484ce8938 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -16,6 +16,7 @@ struct TagEditorNavParams { let tagId: Int64? } +// Spec: spec/client/chat-list.md#TagListView struct TagListView: View { var chat: Chat? = nil @Environment(\.dismiss) var dismiss: DismissAction diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b1cd4015c6..63d28e3624 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -6,6 +6,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#UserPicker struct UserPicker: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 441a164f8a..dbc25e536f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -33,6 +34,7 @@ enum DatabaseEncryptionAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseEncryptionView struct DatabaseEncryptionView: View { @EnvironmentObject private var m: ChatModel @EnvironmentObject private var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 02a1b87826..9610b4a24d 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index a7e61b3105..d5d70abaea 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 19/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -41,6 +42,7 @@ enum DatabaseAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseView struct DatabaseView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 79c0a42ae0..76bdc898d5 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index c21ff9be8b..36608c58d6 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 4a6f8e7549..6df31b4d59 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift index ca30fa5ce8..046a3fd1fc 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 11/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift index 7ec3ee1a42..995b9f5b0d 100644 --- a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 0af8fa7ad8..2ff376701c 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 14.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 93fe19cf33..a28acfcba1 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 23.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 3de1fdb972..71a155949b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.11.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -73,6 +74,7 @@ func showKeepInvitationAlert() { ChatModel.shared.showingInvitation = nil } +// Spec: spec/client/navigation.md#NewChatView struct NewChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -1163,6 +1165,7 @@ private func showOpenKnownGroupAlert( ) } +// Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, theme: AppTheme, diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index c9054f30da..2b38065bd9 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 30/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import CoreImage.CIFilterBuiltins diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift index c8d0faafa7..f22d59fcac 100644 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -5,6 +5,7 @@ // Created by Diogo Cunha on 13/11/2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 33ffa04a50..b5598c1f85 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 31.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index f119beec50..7301c0421d 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 03b0fcba1a..ab84bed7df 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.04.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import Contacts diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index 7452d74e91..263b55a42d 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 08/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 8f448dc508..daef95fbc6 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -5,9 +5,11 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI +// Spec: spec/client/navigation.md#OnboardingView struct OnboardingView: View { var onboarding: OnboardingStage @@ -40,6 +42,7 @@ func onboardingButtonPlaceholder() -> some View { Spacer().frame(height: 40) } +// Spec: spec/client/navigation.md#onboardingStage enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo case step2_CreateProfile // deprecated diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 31865e7af9..717405b03b 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 03/07/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 9f41a37b1d..80f35c1190 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 916e3f9e78..8a7ab465d4 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 24/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 02dec5a618..54a60eed19 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 03/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import SwiftUI import SimpleXChat @@ -21,6 +22,7 @@ let darkThemesWithoutBlackNames: [String] = [DefaultTheme.DARK.themeName, Defaul let appSettingsURL = URL(string: UIApplication.openSettingsURLString)! +// Spec: spec/services/theme.md#AppearanceSettings struct AppearanceSettings: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -313,6 +315,7 @@ struct AppearanceSettings: View { } } +// Spec: spec/services/theme.md#ToolbarMaterial enum ToolbarMaterial: String, CaseIterable { case bar case ultraThin @@ -596,6 +599,7 @@ struct CustomizeThemeView: View { } } +// Spec: spec/services/theme.md#ImportExportThemeSection struct ImportExportThemeSection: View { @EnvironmentObject var theme: AppTheme @Binding var showFileImporter: Bool @@ -632,6 +636,7 @@ struct ImportExportThemeSection: View { } } +// Spec: spec/services/theme.md#ThemeImporter struct ThemeImporter: ViewModifier { @Binding var isPresented: Bool var save: (ThemeOverrides) -> Void @@ -1141,6 +1146,7 @@ private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64 wallpaperFilesToDelete.forEach(removeWallpaperFile) } +// Spec: spec/services/theme.md#decodeYAML private func decodeYAML(_ string: String) -> T? { do { return try YAMLDecoder().decode(T.self, from: string) @@ -1150,6 +1156,7 @@ private func decodeYAML(_ string: String) -> T? { } } +// Spec: spec/services/theme.md#encodeThemeOverrides private func encodeThemeOverrides(_ value: ThemeOverrides) throws -> String { let encoder = YAMLEncoder() encoder.options = YAMLEncoder.Options(sequenceStyle: .block, mappingStyle: .block, newLineScalarStyle: .doubleQuoted) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 3a536c7b17..74d38b050b 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 1e38b7d5ec..6f76e69182 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -5,6 +5,7 @@ // Created by Stanislav Dmitrenko on 26.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import WebKit diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 6f4710396a..64e3d15de0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index c8cb2349e7..b44271bd89 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 13.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index afbccc109c..abd8be03b9 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bfd360cb..97bf9ebc93 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b9737914ec..49e1ff79ea 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift index b28b1a4d1e..fd29fd906e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 19/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index cb6fdf8597..c091224098 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 31/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import StoreKit diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index ddfe59e719..ad3b5cdf95 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -2,6 +2,7 @@ // Created by Avently on 17.01.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 5d619ac130..25df063f82 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import UserNotifications import OSLog @@ -22,6 +23,7 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4) let fastNSESuspendSchedule: SuspendSchedule = (1, 1) +// Spec: spec/services/notifications.md#NSENotificationData public enum NSENotificationData { case connectionEvent(_ user: User, _ connEntity: ConnectionEntity) case contactConnected(_ user: any UserLike, _ contact: Contact) @@ -76,6 +78,7 @@ public enum NSENotificationData { // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid // background crashes and contention for database with the application (both UI and background fetch triggered either on schedule // or when background notification is received. +// Spec: spec/services/notifications.md#NSEThreads class NSEThreads { static let shared = NSEThreads() private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") @@ -238,6 +241,7 @@ class NSEThreads { // NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages. // The reason for this complexity is to process all required messages within allotted 30 seconds, // accounting for the possibility that multiple notifications may be delivered concurrently. +// Spec: spec/services/notifications.md#NotificationEntity struct NotificationEntity { var ntfConn: NtfConn var entityId: ChatId @@ -279,6 +283,7 @@ struct NotificationEntity { // Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never // more than one process of notification service extension exists at a time. // Soon after notification service delivers the last notification it is either suspended or terminated. +// Spec: spec/services/notifications.md#NotificationService class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? // served as notification if no message attempts (msgBestAttemptNtf) could be produced @@ -291,6 +296,7 @@ class NotificationService: UNNotificationServiceExtension { var appSubscriber: AppSubscriber? var returnedSuspension = false + // Spec: spec/services/notifications.md#didReceive override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } @@ -594,6 +600,7 @@ class NotificationService: UNNotificationServiceExtension { serviceBestAttemptNtf = ntf } + // Spec: spec/services/notifications.md#deliverBestAttemptNtf private func deliverBestAttemptNtf(urgent: Bool = false) { logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)") if let handler = contentHandler, urgent || !expectingMoreMessages { @@ -770,6 +777,7 @@ class NotificationService: UNNotificationServiceExtension { } // nseStateGroupDefault must not be used in NSE directly, only via this singleton +// Spec: spec/services/notifications.md#NSEChatState class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created @@ -824,6 +832,7 @@ var networkConfig: NetCfg = getNetCfg() // startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller // Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active + // Spec: spec/services/notifications.md#startChat-NSE func startChat() -> DBMigrationResult? { logger.debug("NotificationService: startChat") // only skip creating if there is chat controller @@ -848,6 +857,7 @@ func startChat() -> DBMigrationResult? { } } + // Spec: spec/services/notifications.md#doStartChat func doStartChat() -> DBMigrationResult? { logger.debug("NotificationService: doStartChat") haskell_init_nse() @@ -940,6 +950,7 @@ func chatSuspended() { // A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state // If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will not be received. + // Spec: spec/services/notifications.md#receiveMessages func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { @@ -988,6 +999,7 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } @inline(__always) + // Spec: spec/services/notifications.md#receivedMsgNtf func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 40cee93faf..85c84a6f45 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -110,6 +110,7 @@ public func resetChatCtrl() { migrationResult = nil } +// Spec: spec/api.md#sendSimpleXCmd @inline(__always) public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult { if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index fce0f100f2..b31a799e68 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import Foundation import SwiftUI @@ -22,6 +23,7 @@ public func onOff(_ b: Bool) -> String { b ? "on" : "off" } +// Spec: spec/api.md#APIResult public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { case result(R) case error(ChatError) @@ -59,6 +61,7 @@ public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { } } +// Spec: spec/api.md#ChatAPIResult public protocol ChatAPIResult: Decodable { var responseType: String { get } var details: String { get } @@ -79,6 +82,7 @@ extension ChatAPIResult { } } +// Spec: spec/api.md#decodeAPIResult public func decodeAPIResult(_ d: Data) -> APIResult { // print("decodeAPIResult \(String(describing: R.self))") do { @@ -691,6 +695,7 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } +// Spec: spec/api.md#ChatError public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) @@ -713,6 +718,7 @@ public enum ChatError: Decodable, Hashable, Error { } } +// Spec: spec/api.md#ChatErrorType public enum ChatErrorType: Decodable, Hashable { case noActiveUser case noConnectionUser(agentConnId: String) diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index da1720c134..ece65130e6 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import SwiftUI +// Spec: spec/services/calls.md#WebRTCCallOffer public struct WebRTCCallOffer: Encodable { public init(callType: CallType, rtcSession: WebRTCSession) { self.callType = callType @@ -19,6 +21,7 @@ public struct WebRTCCallOffer: Encodable { public var rtcSession: WebRTCSession } +// Spec: spec/services/calls.md#WebRTCSession public struct WebRTCSession: Codable { public init(rtcSession: String, rtcIceCandidates: String) { self.rtcSession = rtcSession @@ -29,6 +32,7 @@ public struct WebRTCSession: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { public init(rtcIceCandidates: String) { self.rtcIceCandidates = rtcIceCandidates @@ -37,6 +41,7 @@ public struct WebRTCExtraInfo: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#RcvCallInvitation public struct RcvCallInvitation: Decodable { public var user: User public var contact: Contact @@ -65,6 +70,7 @@ public struct RcvCallInvitation: Decodable { ) } +// Spec: spec/services/calls.md#CallType public struct CallType: Codable { public init(media: CallMediaType, capabilities: CallCapabilities) { self.media = media diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e1bf8614e2..c0b15666d2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md | spec/api.md import Foundation import SwiftUI @@ -1367,6 +1368,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { } } +// Spec: spec/state.md#ChatInfo public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) @@ -1871,6 +1873,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } } +// Spec: spec/state.md#ChatStats public struct ChatStats: Decodable, Hashable { public init( unreadCount: Int = 0, @@ -4234,6 +4237,7 @@ public struct CIFile: Decodable, Hashable { } } +// Spec: spec/services/files.md#CryptoFile public struct CryptoFile: Codable, Hashable { public var filePath: String // the name of the file, not a full path public var cryptoArgs: CryptoFileArgs? @@ -4281,6 +4285,7 @@ public struct CryptoFile: Codable, Hashable { static var decryptedUrls = Dictionary() } +// Spec: spec/services/files.md#CryptoFileArgs public struct CryptoFileArgs: Codable, Hashable { public var fileKey: String public var fileNonce: String diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dfe833f832..5a0d48dced 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -4,6 +4,7 @@ // // Created by Evgeny on 05/09/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. +// Spec: spec/services/files.md // import Foundation @@ -13,6 +14,7 @@ enum WriteFileResult: Decodable { case error(writeError: String) } +// Spec: spec/services/files.md#writeCryptoFile public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { let ptr: UnsafeMutableRawPointer = malloc(data.count) memcpy(ptr, (data as NSData).bytes, data.count) @@ -25,6 +27,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { } } +// Spec: spec/services/files.md#readCryptoFile public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { var cPath = path.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! @@ -47,6 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D } } +// Spec: spec/services/files.md#encryptCryptoFile public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! @@ -58,6 +62,7 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto } } +// Spec: spec/services/files.md#decryptCryptoFile public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws { var cFromPath = fromPath.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 2341eb4a4f..3d0dd663c1 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.04.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/files.md import Foundation import OSLog @@ -13,14 +14,19 @@ import UIKit let logger = Logger() // image file size for complession +// Spec: spec/services/files.md#MAX_IMAGE_SIZE public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB +// Spec: spec/services/files.md#MAX_IMAGE_SIZE_AUTO_RCV public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VOICE_SIZE_AUTO_RCV public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VIDEO_SIZE_AUTO_RCV public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB +// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max @@ -37,10 +43,12 @@ private let CHAT_DB_BAK: String = "_chat.db.bak" private let AGENT_DB_BAK: String = "_agent.db.bak" +// Spec: spec/database.md#getDocumentsDirectory public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } +// Spec: spec/database.md#getGroupContainerDirectory public func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } @@ -51,12 +59,14 @@ func getAppDirectory() -> URL { : getDocumentsDirectory() } +// Spec: spec/database.md#DB_FILE_PREFIX let DB_FILE_PREFIX = "simplex_v1" func getLegacyDatabasePath() -> URL { getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false) } +// Spec: spec/database.md#getAppDatabasePath public func getAppDatabasePath() -> URL { dbContainerGroupDefault.get() == .group ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) @@ -72,6 +82,7 @@ func fileModificationDate(_ path: String) -> Date? { } } +// Spec: spec/services/files.md#deleteAppDatabaseAndFiles public func deleteAppDatabaseAndFiles() { let fm = FileManager.default let dbPath = getAppDatabasePath().path @@ -93,6 +104,7 @@ public func deleteAppDatabaseAndFiles() { storeDBPassphraseGroupDefault.set(true) } +// Spec: spec/services/files.md#deleteAppFiles public func deleteAppFiles() { let fm = FileManager.default do { @@ -183,6 +195,7 @@ public func removeLegacyDatabaseAndFiles() -> Bool { return r1 && r2 } +// Spec: spec/services/files.md#getTempFilesDirectory public func getTempFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) } @@ -191,6 +204,7 @@ public func getMigrationTempFilesDirectory() -> URL { getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true) } +// Spec: spec/services/files.md#getAppFilesDirectory public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } @@ -199,6 +213,7 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } +// Spec: spec/services/files.md#getWallpaperDirectory public func getWallpaperDirectory() -> URL { getAppDirectory().appendingPathComponent("assets", isDirectory: true).appendingPathComponent("wallpapers", isDirectory: true) } @@ -207,6 +222,7 @@ public func getWallpaperFilePath(_ filename: String) -> URL { getWallpaperDirectory().appendingPathComponent(filename) } +// Spec: spec/services/files.md#saveFile public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? { let filePath = getAppFilePath(fileName) do { @@ -223,6 +239,7 @@ public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> Crypt } } +// Spec: spec/services/files.md#removeFile public func removeFile(_ url: URL) { do { try FileManager.default.removeItem(atPath: url.path) @@ -239,12 +256,14 @@ public func removeFile(_ fileName: String) { } } +// Spec: spec/services/files.md#cleanupDirectFile public func cleanupDirectFile(_ aChatItem: AChatItem) { if aChatItem.chatInfo.chatType == .direct { cleanupFile(aChatItem) } } +// Spec: spec/services/files.md#cleanupFile public func cleanupFile(_ aChatItem: AChatItem) { let cItem = aChatItem.chatItem let mc = cItem.content.msgContent diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 31b7ef83ff..24dc58202a 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") +// Spec: spec/services/notifications.md#createContactRequestNtf public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -40,6 +42,7 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User ) } +// Spec: spec/services/notifications.md#createContactConnectedNtf public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -59,6 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, ) } +// Spec: spec/services/notifications.md#createMessageReceivedNtf public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String @@ -78,6 +82,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ ) } +// Spec: spec/services/notifications.md#createCallInvitationNtf public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent { let text = invitation.callType.media == .video ? NSLocalizedString("Incoming video call", comment: "notification") @@ -93,6 +98,7 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCoun ) } +// Spec: spec/services/notifications.md#createConnectionEventNtf public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String @@ -124,6 +130,7 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit ) } +// Spec: spec/services/notifications.md#createErrorNtf public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent { var title: String switch dbStatus { @@ -149,6 +156,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> ) } +// Spec: spec/services/notifications.md#createAppStoppedNtf public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent { return createNotification( categoryIdentifier: ntfCategoryConnectionEvent, @@ -163,6 +171,7 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember : "#\(groupInfo.displayName) \(groupMember.chatViewName):" } +// Spec: spec/services/notifications.md#createNotification public func createNotification( categoryIdentifier: String, title: String, @@ -187,6 +196,7 @@ public func createNotification( return content } +// Spec: spec/services/notifications.md#hideSecrets func hideSecrets(_ cItem: ChatItem) -> String { if let md = cItem.formattedText { var res = "" diff --git a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift index 662f8b43d1..2b64627dc2 100644 --- a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#PresetWallpaper public enum PresetWallpaper: CaseIterable { case cats case flowers @@ -306,6 +307,7 @@ public enum WallpaperScaleType: String, Codable, CaseIterable { } } +// Spec: spec/services/theme.md#WallpaperType public enum WallpaperType: Equatable { public var image: SwiftUI.Image? { if let uiImage { diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index 4074382543..a4e8050c6e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#DefaultTheme public enum DefaultTheme: String, Codable, Equatable { case LIGHT case DARK @@ -39,6 +40,7 @@ public enum DefaultThemeMode: String, Codable { case dark } +// Spec: spec/services/theme.md#Colors public class Colors: ObservableObject, NSCopying, Equatable { @Published public var primary: Color @Published public var primaryVariant: Color @@ -84,6 +86,7 @@ public class Colors: ObservableObject, NSCopying, Equatable { public func clone() -> Colors { copy() as! Colors } } +// Spec: spec/services/theme.md#AppColors public class AppColors: ObservableObject, NSCopying, Equatable { @Published public var title: Color @Published public var primaryVariant2: Color @@ -135,6 +138,7 @@ public class AppColors: ObservableObject, NSCopying, Equatable { } } +// Spec: spec/services/theme.md#AppWallpaper public class AppWallpaper: ObservableObject, NSCopying, Equatable { public static func == (lhs: AppWallpaper, rhs: AppWallpaper) -> Bool { lhs.background == rhs.background && @@ -222,6 +226,7 @@ public enum ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors public struct ThemeColors: Codable, Equatable, Hashable { public var primary: String? = nil public var primaryVariant: String? = nil @@ -293,6 +298,7 @@ public struct ThemeColors: Codable, Equatable, Hashable { } } +// Spec: spec/services/theme.md#ThemeWallpaper public struct ThemeWallpaper: Codable, Equatable, Hashable { public var preset: String? public var scale: Float? @@ -375,6 +381,7 @@ public struct ThemeWallpaper: Codable, Equatable, Hashable { /// If you add new properties, make sure they serialized to YAML correctly, see: /// encodeThemeOverrides() +// Spec: spec/services/theme.md#ThemeOverrides public struct ThemeOverrides: Codable, Equatable, Hashable { public var themeId: String = UUID().uuidString public var base: DefaultTheme @@ -559,6 +566,7 @@ extension [ThemeOverrides] { } +// Spec: spec/services/theme.md#ThemeModeOverrides public struct ThemeModeOverrides: Codable, Hashable { public var light: ThemeModeOverride? = nil public var dark: ThemeModeOverride? = nil @@ -573,6 +581,7 @@ public struct ThemeModeOverrides: Codable, Hashable { } } +// Spec: spec/services/theme.md#ThemeModeOverride public struct ThemeModeOverride: Codable, Equatable, Hashable { public var mode: DefaultThemeMode// = CurrentColors.base.mode public var colors: ThemeColors = ThemeColors() diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md new file mode 100644 index 0000000000..107c0e6569 --- /dev/null +++ b/apps/ios/product/README.md @@ -0,0 +1,258 @@ +# SimpleX Chat iOS -- Product Overview + +> SimpleX Chat iOS product specification. Bidirectional code links: product docs reference source files, source files reference product docs. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Vision](#vision) +2. [Target Users](#target-users) +3. [Capability Map](#capability-map) +4. [Navigation Map](#navigation-map) +5. [Related Specifications](#related-specifications) + +## Executive Summary + +SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. The iOS app is a native SwiftUI application backed by a Haskell core library. + +--- + +## Vision + +SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers. + +The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues. + +--- + +## Target Users + +- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity +- **Groups and communities** needing encrypted group communication with role-based access control +- **Users avoiding identity linkage** who want to communicate without any persistent user identifier +- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers + +--- + +## Capability Map + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Images | Compressed inline images (up to 255KB) | `Shared/Views/Chat/ChatItem/CIImageView.swift` | +| Video | Video message recording and playback | `Shared/Views/Chat/ChatItem/CIVideoView.swift` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | +| File sharing | Files up to 1GB via XFTP protocol | `Shared/Views/Chat/ChatItem/CIFileView.swift` | +| Link previews | OpenGraph metadata extraction and display | `Shared/Views/Chat/ChatItem/CILinkView.swift` | +| Message reactions | Emoji reactions on sent/received messages | `Shared/Views/Chat/ChatItem/EmojiItemView.swift` | +| Message editing | Edit previously sent messages | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift` | +| Timed messages | Self-destructing messages with configurable TTL | `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` | +| Quoted replies | Reply to specific messages with quote context | `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | +| Forwarding | Forward messages between chats | `Shared/Views/Chat/ChatItemForwardingView.swift` | +| Search | Full-text search within conversations | `Shared/Views/Chat/ChatView.swift` | +| Message reports | Report messages to group moderators | `Shared/Views/Chat/ChatView.swift` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `Shared/Views/NewChat/NewChatView.swift` | +| Add via QR code | Scan QR code to establish connection | `Shared/Views/Chat/ScanCodeView.swift` | +| Contact requests | Accept or reject incoming contact requests | `Shared/Views/ChatList/ContactRequestView.swift` | +| Local aliases | Set private display names for contacts | `Shared/Views/Chat/ChatInfoView.swift` | +| Contact verification | Compare security codes out-of-band | `Shared/Views/Chat/VerifyCodeView.swift` | +| Blocking | Block contacts from sending messages | `Shared/Views/Chat/ChatInfoView.swift` | +| Incognito mode | Per-contact random profile generation | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Bot detection | Identify automated/bot contacts | `SimpleXChat/ChatTypes.swift` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Create groups | Create new group with initial members | `Shared/Views/NewChat/AddGroupView.swift` | +| Invite members | Invite by individual contact or link | `Shared/Views/Chat/Group/AddGroupMembersView.swift` | +| Member roles | Owner, admin, moderator, member, observer | `SimpleXChat/ChatTypes.swift` | +| Member admission | Queue-based admission with review workflow | `Shared/Views/Chat/Group/MemberAdmissionView.swift` | +| Group links | Shareable invite links for groups | `Shared/Views/Chat/Group/GroupLinkView.swift` | +| Business chat mode | Structured business communication groups | `Shared/Views/Chat/Group/GroupChatInfoView.swift` | +| Content moderation | Member reports and moderator actions | `Shared/Views/Chat/Group/MemberSupportView.swift` | +| Group preferences | Configure group-level feature settings | `Shared/Views/Chat/Group/GroupPreferencesView.swift` | +| Member direct contacts | Establish direct chats from group membership | `Shared/Views/Chat/Group/GroupMemberInfoView.swift` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `Shared/Views/Call/WebRTCClient.swift` | +| CallKit integration | Native iOS system call UI (ring, answer, decline) | `Shared/Views/Call/CallController.swift` | +| Audio device switching | Switch between speaker, earpiece, Bluetooth | `Shared/Views/Call/CallAudioDeviceManager.swift` | +| Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` | +| Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encryption | Double-ratchet encryption for all messages | `SimpleXChat/API.swift` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `SimpleXChat/ChatTypes.swift` | +| Local authentication | Face ID, Touch ID, or app passcode lock | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Hidden profiles | Password-protected profiles invisible in UI | `Shared/Views/UserSettings/HiddenProfileView.swift` | +| Database encryption | AES encryption of local SQLite database | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Screen privacy | Blur app content when in app switcher | `Shared/Views/UserSettings/PrivacySettings.swift` | +| Encrypted file storage | Local files encrypted at rest | `SimpleXChat/CryptoFile.swift` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Multiple profiles | Multiple user profiles within one app | `Shared/Views/UserSettings/UserProfilesView.swift` | +| Active user switching | Switch between profiles via user picker | `Shared/Views/ChatList/UserPicker.swift` | +| Incognito contacts | Per-contact random identities | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Profile sharing | Share profile via contact address link | `Shared/Views/UserSettings/UserAddressView.swift` | +| User muting | Mute notifications for specific profiles | `Shared/Views/ChatList/UserPicker.swift` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Custom SMP servers | Configure personal SMP relay servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Custom XFTP servers | Configure personal XFTP file servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Tor/onion support | Route traffic through Tor .onion addresses | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `Shared/Views/UserSettings/RTCServers.swift` | +| Network timeouts | Configure connection timeout parameters | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `Shared/Theme/ThemeManager.swift` | +| Wallpapers | Preset and custom chat wallpapers | `Shared/Views/Helpers/ChatWallpaper.swift` | +| Chat bubble styling | Customize message bubble appearance | `SimpleXChat/Theme/ThemeTypes.swift` | +| One-handed UI mode | Compact layout for single-hand use | `Shared/Views/ChatList/OneHandUICard.swift` | +| Language selection | In-app language override | `Shared/Views/UserSettings/AppearanceSettings.swift` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Export/import profiles | Full database export and import | `Shared/Views/Database/DatabaseView.swift` | +| Database encryption | Encrypt/decrypt local database with passphrase | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Local file encryption | Encrypt stored media and attachments | `SimpleXChat/CryptoFile.swift` | +| Storage breakdown | View storage usage by category | `Shared/Views/UserSettings/StorageView.swift` | +| Device-to-device migration | Migrate full profile between iOS devices | `Shared/Views/Migration/MigrateFromDevice.swift` | + +### 10. Desktop Integration + +Remote control of the mobile app from a desktop client. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Remote control pairing | Pair with desktop app via QR code | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | +| Session management | Manage active desktop control sessions | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | + +--- + +## Navigation Map + +``` +Onboarding + OnboardingView.swift + -> SimpleXInfo -> CreateProfile -> ChooseServerOperators -> SetNotificationsMode -> CreateSimpleXAddress + -> ChatListView (home) + +ChatListView (home) + Shared/Views/ChatList/ChatListView.swift + -> ChatView .................. (tap conversation row) + -> NewChatMenuButton ......... (+ button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status) + +ChatView + Shared/Views/Chat/ChatView.swift + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> ChatItemForwardingView .... (long press -> forward) + -> SecondaryChatView ......... (member support thread) + +ChatInfoView + Shared/Views/Chat/ChatInfoView.swift + -> ContactPreferencesView .... (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + Shared/Views/Chat/Group/GroupChatInfoView.swift + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmissionView ....... (admission settings) + -> GroupPreferencesView ...... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> GroupWelcomeView .......... (welcome message) + +NewChatMenuButton + Shared/Views/NewChat/NewChatMenuButton.swift + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + Shared/Views/UserSettings/SettingsView.swift + -> AppearanceSettings ........ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsView ......... (push notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> StorageView ............... (storage usage) + -> VersionView ............... (about/version) + -> DeveloperView ............. (developer options) + +UserPicker + Shared/Views/ChatList/UserPicker.swift + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> PreferencesView ........... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +--- + +## Related Specifications + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Domain term glossary +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- Architecture specification +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Swift model: `Shared/Model/ChatModel.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md new file mode 100644 index 0000000000..a60fe98cbb --- /dev/null +++ b/apps/ios/product/concepts.md @@ -0,0 +1,83 @@ +# SimpleX Chat iOS -- Concept Index + +> SimpleX Chat iOS concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/state.md](../spec/state.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Feature Concepts](#section-1-feature-concepts) +2. [Entity Index](#section-2-entity-index) + +## Executive Summary + +This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Swift iOS layer and the Haskell core library. All source paths are relative to `apps/ios/` for Swift and use `../../src/` prefix for Haskell files (relative to `apps/ios/`). + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | +|---|---------|-------------|-----------|---------------------|----------------------| +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | +| 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | +| 5 | Message Reactions | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/EmojiItemView.swift` | `Controller.hs` (`APIChatItemReaction`) | +| 6 | Message Editing | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `ChatItemInfoView.swift` | `Controller.hs` (`APIUpdateChatItem`) | +| 7 | Message Deletion | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/MarkedDeletedItemView.swift`, `DeletedItemView.swift` | `Controller.hs` (`APIDeleteChatItem`) | +| 8 | Timed Messages | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/CIChatFeatureView.swift` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| 9 | Voice Messages | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ChatItem/CIVoiceView.swift`, `ComposeVoiceView.swift` | `Protocol.hs` (`MCVoice`) | +| 10 | File Transfer | [flows/file-transfer.md](flows/file-transfer.md) | [spec/services/files.md](../spec/services/files.md) | `ChatItem/CIFileView.swift`, `SimpleXChat/FileUtils.swift` | `Files.hs`, `Store/Files.hs` | +| 11 | Link Previews | [views/chat.md](views/chat.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `ChatItem/CILinkView.swift`, `ComposeLinkView.swift` | `Protocol.hs` (`MCLink`) | +| 12 | Contact Connection | [flows/connection.md](flows/connection.md), [views/new-chat.md](views/new-chat.md) | [spec/api.md](../spec/api.md) | `NewChat/NewChatView.swift`, `QRCode.swift` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| 13 | Contact Verification | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `Shared/Views/Chat/VerifyCodeView.swift` | `Controller.hs` (`APIVerifyContact`) | +| 14 | Group Management | [flows/group-lifecycle.md](flows/group-lifecycle.md) | [spec/api.md](../spec/api.md), [spec/database.md](../spec/database.md) | `NewChat/AddGroupView.swift`, `Group/GroupChatInfoView.swift` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| 15 | Group Links | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/GroupLinkView.swift` | `Controller.hs` (`APICreateGroupLink`) | +| 16 | Member Roles | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `SimpleXChat/ChatTypes.swift`, `Group/GroupMemberInfoView.swift` | `Types/Shared.hs` (`GroupMemberRole`) | +| 17 | Audio/Video Calls | [views/call.md](views/call.md), [flows/calling.md](flows/calling.md) | [spec/services/calls.md](../spec/services/calls.md) | `Call/ActiveCallView.swift`, `CallController.swift`, `WebRTCClient.swift` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| 18 | Push Notifications | [views/settings.md](views/settings.md) | [spec/services/notifications.md](../spec/services/notifications.md) | `Model/NtfManager.swift`, `SimpleX NSE/NotificationService.swift` | `Controller.hs` | +| 19 | User Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/state.md](../spec/state.md), [spec/client/navigation.md](../spec/client/navigation.md) | `UserSettings/UserProfilesView.swift`, `ChatList/UserPicker.swift` | `Types.hs` (`User`), `Store/Profiles.hs` | +| 20 | Incognito Mode | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `UserSettings/IncognitoHelp.swift` | `ProfileGenerator.hs`, `Types.hs` | +| 21 | Hidden Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/api.md](../spec/api.md) | `UserSettings/HiddenProfileView.swift` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| 22 | Local Authentication | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `LocalAuth/LocalAuthView.swift`, `PasscodeView.swift` | N/A (iOS-only) | +| 23 | Database Encryption | [views/settings.md](views/settings.md) | [spec/database.md](../spec/database.md) | `Database/DatabaseEncryptionView.swift`, `DatabaseView.swift` | `Controller.hs` (`APIExportArchive`) | +| 24 | Theme System | [views/settings.md](views/settings.md) | [spec/services/theme.md](../spec/services/theme.md) | `Theme/ThemeManager.swift`, `SimpleXChat/Theme/ThemeTypes.swift` | `Types/UITheme.hs` | +| 25 | Network Configuration | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `NetworkAndServers/NetworkAndServers.swift`, `ProtocolServersView.swift` | `Controller.hs` (`APISetNetworkConfig`) | +| 26 | Device Migration | [flows/onboarding.md](flows/onboarding.md) | [spec/database.md](../spec/database.md) | `Migration/MigrateFromDevice.swift`, `MigrateToDevice.swift` | `Archive.hs` | +| 27 | Remote Desktop | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `RemoteAccess/ConnectDesktopView.swift` | `Remote.hs`, `Remote/Types.hs` | +| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | +| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | +| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | + +--- + +## Section 2: Entity Index + +Core data entities, their storage, and the operations that manage their lifecycle. + +| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By | +|--------|-------------------|------------|---------|------------|------------| +| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` | +| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (getContact) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (deleteContact) | +| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroup) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (updateGroupProfile) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (deleteGroup) | +| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroupMember) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (getGroupMembers) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (updateGroupMemberRole) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (deleteGroupMember) | +| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (createNewChatItem) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (getChatItems) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (updateChatItem) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (deleteChatItem) | +| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (getConnectionEntity) | `Store/Connections.hs` (updateConnectionStatus) | `Store/Connections.hs` (deleteConnection) | +| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (getFileTransfer) | `Store/Files.hs` (updateFileStatus, updateFileProgress) | `Store/Files.hs` (deleteFileTransfer) | +| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` | +| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` | +| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.callInvitations` | `CallController.swift`, `IncomingCallView.swift` | Updated on call accept/reject in `CallManager.swift` | Removed on call end/reject; `Controller.hs` | + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Glossary: [glossary.md](glossary.md) +- Haskell core controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell store layer: `../../src/Simplex/Chat/Store/` (Direct.hs, Groups.hs, Messages.hs, Files.hs, Profiles.hs, Connections.hs) +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md new file mode 100644 index 0000000000..86cb026625 --- /dev/null +++ b/apps/ios/product/flows/calling.md @@ -0,0 +1,179 @@ +# Audio/Video Call Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +WebRTC-based audio and video calling in SimpleX Chat iOS. Calls are end-to-end encrypted with an additional shared key negotiated over the E2E encrypted SMP channel. The iOS app integrates with CallKit for native call UI (incoming call screen, lock screen integration) with a fallback mode for regions where CallKit is restricted (China). Call signaling (offer/answer/ICE candidates) is exchanged via SMP messages, not through a central signaling server. + +## Prerequisites + +- Established direct contact connection (calls are 1:1 only, not available in groups) +- Microphone permission granted (audio calls) +- Camera permission granted (video calls) +- Network connectivity for WebRTC peer-to-peer or relay + +## Step-by-Step Processes + +### 1. Initiate Call + +1. User opens a direct chat in `ChatView`. +2. Taps the audio or video call button in the navigation bar. +3. `CallController` determines call type: `CallType(media: .audio/.video, capabilities: CallCapabilities(encryption: true))`. +4. If CallKit is enabled (`CallController.useCallKit()`): + - `CXStartCallAction` is requested via `CXCallController`. + - CallKit reports the outgoing call. + - `provider(perform: CXStartCallAction)` fulfills and reports `reportOutgoingCall(startedConnectingAt:)`. +5. Calls `apiSendCallInvitation(contact:callType:)`: + ```swift + func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws + ``` +6. Sends `ChatCommand.apiSendCallInvitation(contact:callType:)`. +7. Core sends the call invitation to the contact via SMP. +8. `ChatModel.shared.activeCall` is set with the call state. + +### 2. Receive Call + +1. `ChatReceiver` receives `ChatEvent.callInvitation(callInvitation: RcvCallInvitation)`. +2. `RcvCallInvitation` contains: `user`, `contact`, `callType`, `sharedKey`, `callUUID`, `callTs`. +3. Processing in `processReceivedMsg`: + - Call invitation is stored in `chatModel.callInvitations`. +4. If CallKit is enabled: + - `CXProvider.reportNewIncomingCall` presents the native iOS incoming call UI. + - Works even on lock screen and in background. +5. If CallKit is disabled (China / user preference): + - `IncomingCallView` is shown as an in-app overlay. + - `SoundPlayer` plays the ringtone. +6. User chooses to accept or reject. + +### 3. Accept Call + +1. **Via CallKit**: User swipes to accept on the native incoming call screen. + - `provider(perform: CXAnswerCallAction)` is triggered. + - Waits for chat to be started if needed (`waitUntilChatStarted(timeoutMs: 30_000)`). + - `callManager.answerIncomingCall(callUUID:)` begins WebRTC setup. + - `fulfillOnConnect` is set -- the action is fulfilled only when WebRTC reaches connected state (required for audio/mic to work on lock screen). +2. **Via in-app UI**: User taps "Accept" in `IncomingCallView`. + - Directly starts WebRTC setup. + +### 4. Reject Call + +1. **Via CallKit**: User taps "Decline" on native UI. + - `provider(perform: CXEndCallAction)` is triggered. + - `callManager.endCall(callUUID:)` cleans up. +2. **Via API**: `apiRejectCall(contact:)` sends rejection to peer. +3. Call invitation is removed from `chatModel.callInvitations`. + +### 5. WebRTC Setup (Signaling) + +All signaling messages are exchanged via E2E encrypted SMP messages (no central signaling server). + +**Caller side:** +1. `WebRTCClient` creates a `RTCPeerConnection`. +2. Creates SDP offer. +3. Calls `apiSendCallOffer(contact:rtcSession:rtcIceCandidates:media:capabilities:)`: + ```swift + func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, + media: CallMediaType, capabilities: CallCapabilities) async throws + ``` +4. Constructs `WebRTCCallOffer(callType:rtcSession:)` and sends via `ChatCommand.apiSendCallOffer`. +5. Gathers ICE candidates and sends via `apiSendCallExtraInfo(contact:rtcIceCandidates:)`. + +**Callee side:** +1. Receives the offer via SMP. +2. `WebRTCClient` sets remote description from the offer. +3. Creates SDP answer. +4. Calls `apiSendCallAnswer(contact:rtcSession:rtcIceCandidates:)`: + ```swift + func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws + ``` +5. Constructs `WebRTCSession(rtcSession:rtcIceCandidates:)` and sends. +6. Gathers and sends additional ICE candidates via `apiSendCallExtraInfo`. + +### 6. Media Streaming + +1. WebRTC peer connection transitions to connected state. +2. If CallKit is used, `fulfillOnConnect` action is fulfilled (enables audio hardware). +3. Audio/video streams are active. +4. `ActiveCallView` displays: + - Remote video (full screen) + - Local video preview (picture-in-picture corner) + - Call controls: mute, speaker, camera toggle, end call + - Call duration timer +5. `CallViewRenderers` manages WebRTC video rendering surfaces. +6. Call status updates are sent via `apiCallStatus(contact:status:)`. + +### 7. Audio Routing + +1. `CallAudioDeviceManager` handles audio device selection. +2. Options: earpiece (receiver), speaker, Bluetooth devices. +3. `AudioDevicePicker` provides UI for device selection during call. +4. Uses `AVAudioSession` for routing configuration. + +### 8. End Call + +1. Either party taps "End" button. +2. Calls `apiEndCall(contact:)`: + ```swift + func apiEndCall(_ contact: Contact) async throws + ``` +3. Sends `ChatCommand.apiEndCall(contact:)` via SMP to notify peer. +4. `WebRTCClient` closes peer connection, releases media resources. +5. If CallKit: `CXEndCallAction` is requested, `provider(perform: CXEndCallAction)` fulfills. +6. `ChatModel.shared.activeCall` is cleared. +7. A `CICallItemView` event item is added to the chat history (call duration, type). + +### 9. CallKit-Free Mode + +1. `CallController.isInChina` checks `SKStorefront().countryCode == "CHN"`. +2. If in China or user disabled CallKit (`callKitEnabledGroupDefault`): `useCallKit()` returns `false`. +3. Incoming calls use `IncomingCallView` overlay instead of native CallKit UI. +4. `SoundPlayer` handles ringtone playback. +5. No lock-screen call answering; app must be in foreground or notified via push. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CallType` | `SimpleXChat/CallTypes.swift` | `media: CallMediaType` (.audio/.video), `capabilities: CallCapabilities` | +| `CallMediaType` | `SimpleXChat/CallTypes.swift` | `.audio` or `.video` | +| `CallCapabilities` | `SimpleXChat/CallTypes.swift` | `encryption: Bool` for E2E call encryption support | +| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | +| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | +| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | +| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | +| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | +| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | +| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | +| No camera/mic permission | User denied permissions | Permission request dialog shown | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Call/CallController.swift` | CallKit integration, CXProvider delegate, PKPushRegistry, call lifecycle management | +| `Shared/Views/Call/CallManager.swift` | Call state management, starting/answering/ending calls | +| `Shared/Views/Call/WebRTCClient.swift` | WebRTC peer connection, SDP offer/answer, ICE candidate handling | +| `Shared/Views/Call/ActiveCallView.swift` | Active call UI: video renderers, controls, duration | +| `Shared/Views/Call/CallViewRenderers.swift` | WebRTC video rendering surfaces | +| `Shared/Views/Call/IncomingCallView.swift` | Non-CallKit incoming call overlay | +| `Shared/Views/Call/CallAudioDeviceManager.swift` | Audio routing: speaker, earpiece, Bluetooth | +| `Shared/Views/Call/AudioDevicePicker.swift` | Audio device selection UI | +| `Shared/Views/Call/SoundPlayer.swift` | Ringtone and call sound playback | +| `Shared/Views/Call/WebRTC.swift` | WebRTC configuration and utilities | +| `SimpleXChat/CallTypes.swift` | All call-related type definitions | +| `Shared/Model/SimpleXAPI.swift` | Call API functions: `apiSendCallInvitation`, `apiSendCallOffer`, `apiSendCallAnswer`, `apiSendCallExtraInfo`, `apiEndCall`, `apiRejectCall`, `apiCallStatus` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Calls capability +- `apps/ios/product/flows/connection.md` -- Calls require an established direct connection diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md new file mode 100644 index 0000000000..7b9c8ee304 --- /dev/null +++ b/apps/ios/product/flows/connection.md @@ -0,0 +1,159 @@ +# Connection Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Establishing contact between two SimpleX Chat users. SimpleX uses no user identifiers; connections are formed through one-time invitation links or permanent SimpleX addresses. Each connection creates unique unidirectional SMP queues, ensuring no server can correlate sender and receiver. Supports incognito mode for per-contact random profile generation. + +## Prerequisites + +- User profile created and chat engine running +- Network connectivity to SMP relay servers +- For QR code scanning: camera permission granted + +## Step-by-Step Processes + +### 1. Create Invitation Link + +1. User taps "+" button in `ChatListView` -> `NewChatMenuButton` -> "Add contact". +2. `NewChatView` is presented. +3. Calls `apiAddContact(incognito:)`: + ```swift + func apiAddContact(incognito: Bool) async + -> ((CreatedConnLink, PendingContactConnection)?, Alert?) + ``` +4. Internally sends `ChatCommand.apiAddContact(userId:incognito:)` to core. +5. Core creates SMP queues and returns `ChatResponse1.invitation(user, connLinkInv, connection)`. +6. Returns `(CreatedConnLink, PendingContactConnection)`. +7. `CreatedConnLink` contains the invitation URI (both full and short link forms). +8. UI displays: + - QR code rendered by `QRCode` view (scannable by peer) + - Share button to send link via system share sheet + - Copy button for clipboard +9. A `PendingContactConnection` appears in the chat list while awaiting peer. + +### 2. Connect via Link + +1. User receives a SimpleX link (pasted, scanned, or opened via URL scheme). +2. If opened via deep link: `SimpleXApp.onOpenURL` sets `chatModel.appOpenUrl`. +3. For manual entry: User pastes link in `NewChatView`. +4. First, `apiConnectPlan(connLink:inProgress:)` is called to validate: + ```swift + func apiConnectPlan(connLink: String, inProgress: BoxedValue) async + -> ((CreatedConnLink, ConnectionPlan)?, Alert?) + ``` +5. Returns `ConnectionPlan` indicating whether it is an invitation, contact address, or group link, and whether connection is already established. +6. If valid, calls `apiConnect(incognito:connLink:)`: + ```swift + func apiConnect(incognito: Bool, connLink: CreatedConnLink) async + -> (ConnReqType, PendingContactConnection)? + ``` +7. Core creates the connection and returns one of: + - `ChatResponse1.sentConfirmation(user, connection)` -- for invitation links (type: `.invitation`) + - `ChatResponse1.sentInvitation(user, connection)` -- for contact address links (type: `.contact`) + - `ChatResponse1.contactAlreadyExists(user, contact)` -- duplicate +8. `PendingContactConnection` appears in chat list while awaiting peer confirmation. + +### 3. Prepared Contact/Group Flow (Short Links) + +1. For short links with embedded profile data, the app uses a two-phase flow. +2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:groupShortLinkData:)` creates a local prepared chat. +3. Returns `ChatData` with the prepared contact/group shown in UI before connecting. +4. User can switch profiles or set incognito before committing. +5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection. +6. Returns `ChatResponse1.startedConnectionToContact(user, contact)`. + +### 4. Accept Contact Request + +1. When a peer connects via the user's SimpleX address, core generates a `ChatEvent.receivedContactRequest`. +2. `processReceivedMsg` handles the event, adding a `UserContactRequest` to `ChatModel`. +3. Contact request appears in `ChatListView` as a special `ContactRequestView` row. +4. User taps "Accept": + ```swift + func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? + ``` +5. Sends `ChatCommand.apiAcceptContact(incognito:contactReqId:)`. +6. Core returns `ChatResponse1.acceptingContactRequest(user, contact)`. +7. Connection handshake proceeds asynchronously. +8. User can also reject: `apiRejectContactRequest(contactReqId:)` -> `ChatResponse1.contactRequestRejected`. + +### 5. Connection Established + +1. Both sides complete the SMP handshake asynchronously. +2. Core sends `ChatEvent.contactConnected(user, contact, userCustomProfile)`. +3. `processReceivedMsg` updates `ChatModel`: + - Contact status transitions from pending to active. + - Chat becomes available for messaging. +4. `NtfManager` may post a notification: "Contact connected". +5. The `PendingContactConnection` in the chat list is replaced by the full contact chat. + +### 6. Create SimpleX Address + +1. User navigates to Settings or taps "Create SimpleX address" during onboarding. +2. Calls `apiCreateUserAddress()`: + ```swift + func apiCreateUserAddress() async throws -> CreatedConnLink? + ``` +3. Core creates a permanent address (unlike one-time invitations). +4. Address is stored in `ChatModel.shared.userAddress`. +5. Can be shared publicly; multiple contacts can connect via the same address. +6. User must accept each incoming contact request individually. +7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues. + +### 7. Incognito Connection + +1. Before connecting, user toggles "Incognito" in the connection UI. +2. `incognito: true` is passed to `apiAddContact`, `apiConnect`, or `apiAcceptContactRequest`. +3. Core generates a random display name for this connection only. +4. The random profile is stored per-connection; the user's real profile is never shared. +5. Incognito status is shown with a mask icon in the chat. +6. Can also be toggled for pending connections via `apiSetConnectionIncognito(connId:incognito:)`. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CreatedConnLink` | `SimpleXChat/APITypes.swift` | Contains `connFullLink` (URI) and optional `connShortLink` | +| `PendingContactConnection` | `SimpleXChat/ChatTypes.swift` | Represents an in-progress connection before contact is established | +| `ConnectionPlan` | `Shared/Model/AppAPITypes.swift` | Enum describing what a link will do: connect contact, join group, already connected, etc. | +| `ConnReqType` | `Shared/Views/NewChat/NewChatView.swift` | `.invitation`, `.contact`, or `.groupLink` -- type of connection request | +| `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences | +| `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance | +| `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.invalidConnReq` | Malformed or expired link | Alert: "Invalid connection link" | +| `ChatError.unsupportedConnReq` | Link requires newer app version | Alert: "Unsupported connection link" | +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Link already used or deleted | Alert: "Connection error (AUTH)" | +| `ChatError.errorAgent(.SMP(_, .BLOCKED(info)))` | Server operator blocked connection | Alert: "Connection blocked" with reason | +| `ChatError.errorAgent(.SMP(_, .QUOTA))` | Too many undelivered messages | Alert: "Undelivered messages" | +| `ChatError.errorAgent(.INTERNAL("SEUniqueID"))` | Duplicate connection attempt | Alert: "Already connected?" | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable via `chatApiSendCmdWithRetry` | +| `contactAlreadyExists` | Connecting to existing contact | Alert: "Contact already exists" with contact name | +| `errorAgent(.SMP(_, .AUTH))` on accept | Sender deleted request | Alert: "Sender may have deleted the connection request" | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/NewChatView.swift` | Main connection UI: create link, paste link, QR scan | +| `Shared/Views/NewChat/NewChatMenuButton.swift` | "+" button menu in chat list | +| `Shared/Views/NewChat/QRCode.swift` | QR code rendering for invitation links | +| `Shared/Views/NewChat/AddContactLearnMore.swift` | Help text explaining connection process | +| `Shared/Views/ChatList/ContactRequestView.swift` | Incoming contact request display | +| `Shared/Views/ChatList/ContactConnectionView.swift` | Pending connection display | +| `Shared/Views/ChatList/ContactConnectionInfo.swift` | Connection details sheet | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiAddContact`, `apiConnect`, `apiConnectPlan`, `apiAcceptContactRequest`, `apiCreateUserAddress` | +| `Shared/Model/AppAPITypes.swift` | `ConnectionPlan` enum, `GroupLink` struct | +| `SimpleXChat/APITypes.swift` | `CreatedConnLink`, `ComposedMessage`, command/response types | +| `SimpleXChat/ChatTypes.swift` | `Contact`, `PendingContactConnection`, `UserContactRequest` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Contacts capability map +- `apps/ios/product/flows/messaging.md` -- Messaging after connection is established diff --git a/apps/ios/product/flows/file-transfer.md b/apps/ios/product/flows/file-transfer.md new file mode 100644 index 0000000000..0b4b0538cc --- /dev/null +++ b/apps/ios/product/flows/file-transfer.md @@ -0,0 +1,209 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +File and media sharing in SimpleX Chat iOS. Small files are sent inline within SMP messages; large files use the XFTP (eXtended File Transfer Protocol) for chunked, encrypted uploads up to 1GB. All files are encrypted end-to-end. Optional local encryption protects downloaded files at rest using AES via `CryptoFile`. + +## Prerequisites + +- Established contact or group conversation +- For sending: photo library or file picker access permission +- For receiving: sufficient device storage +- XFTP relay servers configured (default servers or custom) + +## Size Limits + +| Category | Limit | Constant | +|----------|-------|----------| +| Inline image (compressed) | 255 KB | `MAX_IMAGE_SIZE` = 261,120 bytes | +| Auto-receive image | 510 KB | `MAX_IMAGE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive voice | 510 KB | `MAX_VOICE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive video | 1,023 KB | `MAX_VIDEO_SIZE_AUTO_RCV` = 1,047,552 bytes | +| Max file via XFTP | 1 GB | `MAX_FILE_SIZE_XFTP` = 1,073,741,824 bytes | +| Max file via SMP | ~8 MB | `MAX_FILE_SIZE_SMP` = 8,000,000 bytes | +| Max voice message length | 5 min | `MAX_VOICE_MESSAGE_LENGTH` = 300s | + +## Step-by-Step Processes + +### 1. Send Image + +1. User taps the attachment button in `ComposeView` and selects an image. +2. `ComposeImageView` displays the selected image preview. +3. Image is compressed to fit within `MAX_IMAGE_SIZE` (255KB). +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: compressedImagePath), + msgContent: .image(text: captionText, image: base64Thumbnail) + ) + ``` +5. `apiSendMessages(type:id:scope:composedMessages:)` is called. +6. For images <=255KB: sent inline within the SMP message. +7. For larger images: XFTP upload is used (see XFTP transfer below). +8. Recipient auto-receives images up to 510KB (`MAX_IMAGE_SIZE_AUTO_RCV`). + +### 2. Send Video + +1. User picks a video from the library. +2. Thumbnail is generated from the first frame. +3. Video duration is calculated. +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: videoFilePath), + msgContent: .video(text: captionText, image: base64Thumbnail, duration: durationSeconds) + ) + ``` +5. `apiSendMessages(...)` is called. +6. Video files are typically >255KB, so XFTP upload is used. +7. Recipient auto-receives videos up to 1,023KB (`MAX_VIDEO_SIZE_AUTO_RCV`). +8. `CIVideoView` displays thumbnail with play button; video downloads on tap if not auto-received. + +### 3. Send File + +1. User taps the attachment button and selects a document via the system file picker. +2. `ComposeFileView` shows the file name and size. +3. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: filePath), + msgContent: .file(fileName) + ) + ``` +4. `apiSendMessages(...)` is called. +5. If file <=255KB: sent inline via SMP. +6. If file >255KB and <=1GB: uploaded via XFTP. +7. Files >1GB: rejected (prevented in UI). +8. `CIFileView` displays file icon, name, and size for the recipient. + +### 4. Send Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` records audio to a temporary file. +3. `ComposeVoiceView` shows recording waveform and duration. +4. On release (or tapping stop), recording ends. +5. Duration is checked against `MAX_VOICE_MESSAGE_LENGTH` (5 minutes / 300 seconds). +6. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: voiceFilePath), + msgContent: .voice(text: "", duration: durationSeconds) + ) + ``` +7. `apiSendMessages(...)` is called. +8. Voice messages <=510KB are sent inline. +9. Recipient auto-receives voice up to 510KB (`MAX_VOICE_SIZE_AUTO_RCV`). +10. `CIVoiceView` renders waveform with playback controls. + +### 5. Receive File + +1. Core receives a message with a file reference via SMP. +2. `ChatEvent.newChatItems` delivers the chat item with file metadata. +3. Auto-receive logic checks: + - File type and size against auto-receive thresholds. + - User's auto-receive preferences. +4. If auto-received or user taps "Download": + ```swift + func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async + ``` +5. Internally calls `receiveFiles(user:fileIds:userApprovedRelays:auto:)`. +6. Sends `ChatCommand.receiveFile(fileId:userApprovedRelays:encrypted:inline:)`. +7. `encrypted` is determined by `privacyEncryptLocalFilesGroupDefault`. +8. `userApprovedRelays` controls whether unknown XFTP relay servers are trusted. +9. On success: `ChatResponse2.rcvFileAccepted(user, chatItem)` -- file download begins. +10. On sender cancelled: `ChatResponse2.rcvFileAcceptedSndCancelled(user, rcvFileTransfer)`. +11. Download progress is tracked and shown in the UI. +12. Completed files are stored in the app's `Documents/files/` directory. + +### 6. XFTP Transfer (Large Files) + +**Upload (sender side):** +1. File is encrypted locally with a random symmetric key. +2. Encrypted file is split into chunks. +3. Chunks are uploaded to one or more XFTP relay servers. +4. A file description (URI with encryption key and chunk locations) is created. +5. The file description is sent to the recipient via the SMP message. + +**Download (recipient side):** +1. Recipient receives the file description via SMP. +2. Chunks are downloaded from XFTP relay servers. +3. Chunks are reassembled and decrypted locally. +4. File is available at the local path. + +**Standalone file operations** (used for database migration): +- `uploadStandaloneFile(user:file:ctrl:)` -- upload without a chat message +- `downloadStandaloneFile(user:url:file:ctrl:)` -- download from a standalone URL +- `standaloneFileInfo(url:ctrl:)` -- get metadata for a standalone file URL + +### 7. Local File Encryption + +1. If `privacyEncryptLocalFilesGroupDefault` is enabled in privacy settings: + - Downloaded files are encrypted at rest using AES via `CryptoFile`. + - `CryptoFile` wraps a file path with encryption metadata. +2. Encryption key is derived and stored securely. +3. Files are decrypted on-the-fly when accessed for viewing/playback. +4. This protects files even if the device storage is accessed externally. + +### 8. Unknown Relay Server Approval + +1. When receiving a file, XFTP relay servers are checked against known/approved servers. +2. If unknown servers are detected: `ChatError.error(.fileNotApproved(fileId, unknownServers))`. +3. If not auto-receiving, user is shown an alert: + - "Unknown servers! Without Tor or VPN, your IP address will be visible to these XFTP relays: [server list]." + - Option to "Download" (approve) or cancel. +4. On approval: `receiveFiles(user:fileIds:userApprovedRelays: true)` retries with approval. +5. If `privacyAskToApproveRelaysGroupDefault` is disabled, relays are auto-approved. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CryptoFile` | `SimpleXChat/CryptoFile.swift` | File path with optional encryption key and nonce for local AES encryption | +| `MsgContent.image` | `SimpleXChat/ChatTypes.swift` | `.image(text: String, image: String)` -- text caption + base64 thumbnail | +| `MsgContent.video` | `SimpleXChat/ChatTypes.swift` | `.video(text: String, image: String, duration: Int)` -- caption + thumbnail + duration | +| `MsgContent.voice` | `SimpleXChat/ChatTypes.swift` | `.voice(text: String, duration: Int)` -- empty text + duration in seconds | +| `MsgContent.file` | `SimpleXChat/ChatTypes.swift` | `.file(String)` -- file name | +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message with fileSource, quotedItemId, msgContent, mentions | +| `FileTransferMeta` | `SimpleXChat/ChatTypes.swift` | Metadata for an ongoing file transfer | +| `RcvFileTransfer` | `SimpleXChat/ChatTypes.swift` | State of a file being received | +| `MigrationFileLinkData` | Used for standalone file transfers during database migration | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `fileNotApproved(fileId, unknownServers)` | Unknown XFTP relay servers | Alert with option to approve and retry | +| `fileCancelled` | File transfer was cancelled | Silently ignored in `receiveFiles` | +| `fileAlreadyReceiving` | Duplicate receive request | Silently ignored | +| `rcvFileAcceptedSndCancelled` | Sender cancelled after acceptance | Alert: "Sender cancelled file transfer" | +| File too large | Exceeds 1GB XFTP limit | Prevented in UI picker | +| Network errors | XFTP server unreachable | Standard retry mechanism | +| Storage full | Insufficient device storage | System-level error | + +## Key Files + +| File | Purpose | +|------|---------| +| `SimpleXChat/FileUtils.swift` | File size constants, path utilities, database file management | +| `SimpleXChat/CryptoFile.swift` | Local file encryption/decryption with AES | +| `SimpleXChat/ImageUtils.swift` | Image compression and thumbnail generation | +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | File/media attachment selection and composition | +| `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` | Image preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` | File preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` | Voice recording UI with waveform | +| `Shared/Views/Chat/ChatItem/CIFileView.swift` | File message display: icon, name, size, download action | +| `Shared/Views/Chat/ChatItem/CIImageView.swift` | Image message display: thumbnail, full-screen tap | +| `Shared/Views/Chat/ChatItem/CIVideoView.swift` | Video message display: thumbnail, play button, inline playback | +| `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | Voice message display: waveform, playback controls | +| `Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift` | Voice message inside a framed (quoted/forwarded) context | +| `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` | Full-screen image/video viewer | +| `Shared/Model/SimpleXAPI.swift` | `apiSendMessages`, `receiveFile`, `receiveFiles`, `uploadStandaloneFile`, `downloadStandaloneFile` | +| `Shared/Model/AudioRecPlay.swift` | Audio recording and playback engine for voice messages | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Messaging capability (file sharing) +- `apps/ios/product/flows/messaging.md` -- File transfer is part of the message send flow +- `apps/ios/product/views/chat.md` -- Chat view file/media display diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..78d4f28738 --- /dev/null +++ b/apps/ios/product/flows/group-lifecycle.md @@ -0,0 +1,216 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/database.md](../../spec/database.md) + +## Overview + +Complete group management in SimpleX Chat iOS: creating groups, inviting members, joining via links, managing roles and admission, and group deletion. Groups use the same E2E encryption as direct messages -- each member pair has independent encrypted channels. Group metadata (name, image, preferences) is distributed via the group protocol. + +## Prerequisites + +- User profile created and chat engine running +- At least one established contact (to invite to a group) +- For joining via link: a valid group link or invitation + +## Step-by-Step Processes + +### 1. Create Group + +1. User taps "+" in `ChatListView` -> `NewChatMenuButton` -> "Create group". +2. `AddGroupView` is presented for entering group name, optional image, and description. +3. User fills in `GroupProfile(displayName:fullName:image:description:)` and taps "Create". +4. Calls `apiNewGroup(incognito:groupProfile:)`: + ```swift + func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo + ``` +5. Sends `ChatCommand.apiNewGroup(userId:incognito:groupProfile:)` to core (synchronous). +6. Core returns `ChatResponse2.groupCreated(user, groupInfo)`. +7. `GroupInfo` contains the new group's ID, profile, and the creator as owner. +8. User is navigated to `AddGroupMembersView` to optionally invite contacts. +9. User can also create a group link at this stage. + +### 2. Invite Members + +1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`. +2. `filterMembersToAdd` filters contacts already in the group. +3. User selects contacts and assigns roles (default: `.member`). +4. For each selected contact, calls `apiAddMember(groupId:contactId:memberRole:)`: + ```swift + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember + ``` +5. Core sends group invitation to the contact and returns `ChatResponse2.sentGroupInvitation(user, _, _, member)`. +6. The invited contact receives a `CIGroupInvitationView` in their chat. +7. Invited member's status is `.invited` until they accept. + +### 3. Join via Link + +1. User receives a group link (scanned or pasted). +2. `apiConnectPlan` validates the link and identifies it as a group link. +3. For prepared groups (short links): `apiPrepareGroup(connLink:groupShortLinkData:)` shows group info before joining. +4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining. +5. Core processes the join request. Depending on group admission settings: + - **Auto-join**: Member is added immediately. + - **Approval required**: Member enters pending admission queue. +6. `apiJoinGroup(groupId:)` is called for invitation-based joins: + ```swift + func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? + ``` +7. Returns one of: + - `.joined(groupInfo:)` -- successfully joined + - `.invitationRemoved` -- invitation was revoked (SMP AUTH error) + - `.groupNotFound` -- group no longer exists + +### 4. Member Admission + +1. Group has admission settings configured via `MemberAdmissionView`. +2. When a new member joins a group requiring approval, admins see pending members. +3. Admin reviews pending member in the member list. +4. To accept: `apiAcceptMember(groupId:groupMemberId:memberRole:)`: + ```swift + func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) + ``` +5. Core returns `ChatResponse2.memberAccepted(user, groupInfo, member)`. +6. To reject: remove the pending member (same as member removal). +7. Member support chat (`MemberSupportView`, `MemberSupportChatToolbar`) allows admins to communicate with pending members. + +### 5. Change Member Roles + +1. Admin/owner navigates to member info in `GroupChatInfoView`. +2. Selects new role for the member. +3. Calls `apiMembersRole(groupId:memberIds:memberRole:)`: + ```swift + func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] + ``` +4. Core returns `ChatResponse2.membersRoleUser(user, _, members, _)`. +5. Available roles (in hierarchy order): + - `.owner` -- full control, can delete group + - `.admin` -- can manage members, change roles (below admin) + - `.moderator` -- can delete messages, moderate content + - `.member` -- standard participant, can send messages + - `.observer` -- read-only access +6. Role changes are broadcast to all group members as group events. + +### 6. Remove Member + +1. Admin/owner navigates to member info -> taps "Remove". +2. Calls `apiRemoveMembers(groupId:memberIds:withMessages:)`: + ```swift + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) + ``` +3. `withMessages: true` also deletes all messages from that member. +4. Core returns `ChatResponse2.userDeletedMembers(user, updatedGroupInfo, members, withMessages)`. +5. Removed member receives notification and loses access. + +### 7. Block Member for All + +1. Admin can block a member's messages from being visible to all group members. +2. Calls `apiBlockMembersForAll(groupId:memberIds:blocked:)`: + ```swift + func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] + ``` +3. Core returns `ChatResponse2.membersBlockedForAllUser(user, _, members, _)`. + +### 8. Leave Group + +1. User navigates to `GroupChatInfoView` -> taps "Leave group". +2. Confirmation dialog is presented. +3. Calls `leaveGroup(groupId:)` which wraps `apiLeaveGroup(groupId:)`: + ```swift + func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo + ``` +4. Core returns `ChatResponse2.leftMemberUser(user, groupInfo)`. +5. `ChatModel.shared.updateGroup(groupInfo)` updates the UI. +6. User retains local chat history but can no longer send/receive. + +### 9. Delete Group + +1. Owner navigates to `GroupChatInfoView` -> taps "Delete group". +2. Calls `apiDeleteChat(type: .group, id: groupId)`: + ```swift + func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws + ``` +3. Core notifies all members and removes the group. +4. Chat is removed from `ChatModel.shared.chats`. + +### 10. Group Link Management + +**Create group link:** +1. From `GroupLinkView` (accessible via `GroupChatInfoView`). +2. Calls `apiCreateGroupLink(groupId:memberRole:)`: + ```swift + func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? + ``` +3. Returns `GroupLink` containing the link URI and member role. +4. Optional: `apiAddGroupShortLink(groupId:)` generates an additional short link. + +**Update link role:** +- `apiGroupLinkMemberRole(groupId:memberRole:)` changes the default role for new joiners. + +**Delete group link:** +- `apiDeleteGroupLink(groupId:)` invalidates the link. + +**Get existing link:** +- `apiGetGroupLink(groupId:)` retrieves the current link (returns `nil` if none exists). + +### 11. Group Preferences + +1. `GroupPreferencesView` allows configuring per-feature preferences. +2. Features controlled include: + - Timed/disappearing messages + - Message reactions + - Voice messages + - File sharing + - Direct messages between members + - Full message deletion + - Message history visibility for new members +3. Changes are saved via `apiUpdateGroup(groupId:groupProfile:)` with updated preferences. +4. `GroupWelcomeView` manages the welcome message shown to new joiners. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info | +| `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences | +| `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info | +| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer` | +| `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. | +| `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data | +| `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats | +| `JoinGroupResult` | `Shared/Model/SimpleXAPI.swift` | `.joined(groupInfo)`, `.invitationRemoved`, `.groupNotFound` | +| `GMember` | `Shared/Views/Chat/Group/` | View-layer wrapper around `GroupMember` for list display | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `errorStore(.groupNotFound)` | Group deleted or not accessible | `JoinGroupResult.groupNotFound` | +| `errorAgent(.SMP(_, .AUTH))` | Invitation revoked | `JoinGroupResult.invitationRemoved` | +| `errorStore(.groupLinkNotFound)` | No group link exists | `apiGetGroupLink` returns `nil` | +| `duplicateGroupLink` | Link already exists for group | Show alert | +| `errorAgent(.NOTICE(server, preset, expires))` | Server notice during link creation | `showClientNotice` alert | +| Network errors | SMP/XFTP server unreachable | Retryable via `chatApiSendCmdWithRetry` | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/AddGroupView.swift` | Group creation UI | +| `Shared/Views/Chat/Group/AddGroupMembersView.swift` | Member invitation UI | +| `Shared/Views/Chat/Group/GroupLinkView.swift` | Group link management UI | +| `Shared/Views/Chat/Group/GroupProfileView.swift` | Group profile editing | +| `Shared/Views/Chat/Group/GroupPreferencesView.swift` | Feature preferences UI | +| `Shared/Views/Chat/Group/GroupWelcomeView.swift` | Welcome message editing | +| `Shared/Views/Chat/Group/MemberAdmissionView.swift` | Admission settings UI | +| `Shared/Views/Chat/Group/MemberSupportView.swift` | Admin-to-pending-member chat | +| `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` | Support chat accept/reject toolbar | +| `Shared/Views/Chat/Group/SecondaryChatView.swift` | Secondary chat view for member support | +| `Shared/Model/SimpleXAPI.swift` | All group API functions | +| `Shared/Model/AppAPITypes.swift` | `GroupLink`, `ConnectionPlan` | +| `SimpleXChat/ChatTypes.swift` | `GroupInfo`, `GroupProfile`, `GroupMember`, `GroupMemberRole` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Groups capability map +- `apps/ios/product/flows/connection.md` -- Connection flow (group links use the same connect mechanism) +- `apps/ios/product/flows/messaging.md` -- Messaging within groups diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md new file mode 100644 index 0000000000..527079995c --- /dev/null +++ b/apps/ios/product/flows/messaging.md @@ -0,0 +1,178 @@ +# Messaging Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Overview + +Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, editing, deleting, reacting to, replying to, and forwarding messages. All messages are end-to-end encrypted via the SMP protocol. The Haskell core handles encryption, routing, and persistence; the Swift UI layer drives composition and display. + +## Prerequisites + +- User profile created and chat engine running (`startChat()` completed) +- At least one established contact or group conversation +- `ChatModel.shared` populated with chat list data + +## Step-by-Step Processes + +### 1. Send Text Message + +1. User navigates to a conversation (direct or group) via `ChatListView` -> `ChatView`. +2. User types text into `ComposeView`'s `SendMessageView` text editor. +3. Link previews are detected and fetched asynchronously (`ComposeLinkView`). +4. User taps the send button. +5. `ComposeView` builds a `ComposedMessage`: + ```swift + ComposedMessage( + fileSource: nil, + quotedItemId: nil, + msgContent: .text("Hello"), + mentions: [:] + ) + ``` +6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:)`. +7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core. +8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`. +9. `processSendMessageCmd` extracts `[ChatItem]` from response. +10. For direct chats, a background task tracks delivery via `chatModel.messageDelivery`. +11. `ChatModel` updates, UI refreshes to show the new message. + +### 2. Send Media (Image/Video/File) + +1. User taps the attachment button in `ComposeView`. +2. **Image**: Picked via `PhotosPicker` or camera. Compressed to <=255KB. Sent inline with `.image(text, base64Image)` content type. +3. **Video**: Picked from library. Thumbnail generated. Video file sent via XFTP for files >255KB. Content type: `.video(text, thumbnail, duration)`. +4. **File**: Picked via document picker. If <=255KB, sent inline. If >255KB, uploaded via XFTP (up to 1GB). Content type: `.file(text)`. +5. `ComposedMessage` includes `fileSource: CryptoFile(filePath:)`. +6. `apiSendMessages(...)` called with the composed message array. +7. Core handles XFTP upload for large files (chunked, encrypted upload to XFTP servers). +8. Recipient receives file reference and can download. + +### 3. Receive Message + +1. `ChatReceiver.shared` runs `receiveMsgLoop()` continuously calling `chatRecvMsg()`. +2. Core delivers events via `APIResult`. +3. On `ChatEvent.newChatItems(user, chatItems)`: + - `processReceivedMsg` is called. + - For the active user, `ChatModel` is updated with new items. + - If the chat is currently open, `ItemsModel` appends to `reversedChatItems`. + - `NtfManager` posts a local notification if the app is in the background. +4. Small files/images attached to incoming messages are auto-received if within size thresholds. + +### 4. Edit Message + +1. User long-presses a sent message -> selects "Edit" from context menu. +2. `ComposeView` enters edit mode with the original text pre-filled. +3. User modifies text and taps send. +4. Calls `apiUpdateChatItem(type:id:scope:itemId:updatedMessage:live:)`. +5. Dispatches `ChatCommand.apiUpdateChatItem(...)`. +6. Core returns `ChatResponse1.chatItemUpdated(user, aChatItem)` or `.chatItemNotChanged(user, aChatItem)`. +7. `ChatModel` updates the item in place. Edit timestamp is shown in the UI. + +### 5. Delete Message + +1. User long-presses a message -> selects "Delete". +2. Presented with options: + - **Delete for me** (`CIDeleteMode.cidmInternal`) -- removes locally only. + - **Delete for everyone** (`CIDeleteMode.cidmBroadcast`) -- sends deletion to recipient(s). +3. Calls `apiDeleteChatItems(type:id:scope:itemIds:mode:)`. +4. Dispatches `ChatCommand.apiDeleteChatItem(type:id:scope:itemIds:mode:)`. +5. Core returns `ChatResponse1.chatItemsDeleted(user, items, _)` containing `[ChatItemDeletion]`. +6. For group messages from other members, admin/owner can call `apiDeleteMemberChatItems(groupId:itemIds:)`. +7. `ChatModel` removes or replaces items with "deleted" placeholders. + +### 6. React to Message + +1. User long-presses a message -> selects "React" -> picks an emoji. +2. Calls `apiChatItemReaction(type:id:scope:itemId:add:reaction:)`. +3. `reaction` is `MsgReaction` (e.g., `.emoji(.heart)`). +4. `add: true` to add, `add: false` to remove. +5. Core returns `ChatResponse1.chatItemReaction(user, _, reaction)`. +6. The reaction is displayed below the message bubble. + +### 7. Reply to Message + +1. User long-presses a message -> selects "Reply". +2. `ComposeView` enters reply mode, showing quoted message in `ContextItemView`. +3. User types reply text and taps send. +4. `ComposedMessage` is created with `quotedItemId: originalItem.id`. +5. `apiSendMessages(...)` sends with the quote reference. +6. Recipient sees the reply with the quoted context rendered above. + +### 8. Forward Message + +1. User long-presses a message -> selects "Forward". +2. `ChatItemForwardingView` is presented for destination chat selection. +3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`. +4. User confirms and selects destination chat. +5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:)`. +6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat. + +### 9. Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` starts recording to a temporary file. +3. On release, recording stops. Duration is calculated (max 5 minutes / 300 seconds). +4. `ComposedMessage` created with: + - `fileSource: CryptoFile` pointing to the audio file + - `msgContent: .voice(text: "", duration: seconds)` +5. `apiSendMessages(...)` sends the voice message. +6. Voice messages <=510KB sent inline; larger via XFTP. +7. Recipient sees `CIVoiceView` with waveform and playback controls. + +### 10. Delivery Tracking + +1. On send, message status starts as `CIStatus.sndNew`. +2. After SMP delivery: `CIStatus.sndSent(sndProgress)`. +3. When delivered to recipient's agent: status updates to delivered. +4. If delivery receipts are enabled by both parties, read status is reported. +5. Failed delivery results in `CIStatus.sndError*` or `CIStatus.sndWarning*`. +6. Status is displayed via `CIMetaView` (checkmarks/indicators). + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message: fileSource, quotedItemId, msgContent, mentions | +| `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` | +| `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants | +| `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead | +| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)` | +| `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem | +| `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs | +| `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) | +| `MsgReaction` | `SimpleXChat/ChatTypes.swift` | Reaction type (emoji-based) | +| `UpdatedMessage` | `SimpleXChat/APITypes.swift` | Edited message content for update API | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Recipient queue issue | Show "Connection error (AUTH)" alert | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable: show retry dialog via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable: show retry dialog | +| Send message error | Core processing failure | `sendMessageErrorAlert` shown to user | +| `chatItemNotChanged` | Edit with identical content | No error, item returned unchanged | +| File too large (>1GB) | XFTP limit exceeded | Prevented in UI file picker | +| `fileNotApproved` | Unknown XFTP relay servers | Show "Unknown servers!" alert with approve option | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | Message composition UI and send logic | +| `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` | Text input and send button | +| `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | Reply/edit context display | +| `Shared/Views/Chat/ChatItemView.swift` | Per-message rendering dispatcher | +| `Shared/Views/Chat/ChatItem/MsgContentView.swift` | Text message content with markdown | +| `Shared/Views/Chat/ChatItem/CIMetaView.swift` | Delivery status indicators | +| `Shared/Views/Chat/ChatItemForwardingView.swift` | Forward destination picker | +| `Shared/Views/Chat/ChatItemInfoView.swift` | Message info (delivery details, timestamps) | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiSendMessages`, `apiUpdateChatItem`, `apiDeleteChatItems`, `apiChatItemReaction`, `apiForwardChatItems` | +| `SimpleXChat/APITypes.swift` | `ComposedMessage`, `ChatCommand` enum, response types | +| `SimpleXChat/ChatTypes.swift` | `MsgContent`, `CIContent`, `CIStatus`, `CIDirection`, `ChatItem` | +| `Shared/Model/AudioRecPlay.swift` | Voice message recording/playback engine | + +## Related Specifications + +- `apps/ios/product/views/chat.md` -- Chat view UI specification +- `apps/ios/product/README.md` -- Product overview and capability map diff --git a/apps/ios/product/flows/onboarding.md b/apps/ios/product/flows/onboarding.md new file mode 100644 index 0000000000..5e2e04d42a --- /dev/null +++ b/apps/ios/product/flows/onboarding.md @@ -0,0 +1,239 @@ +# Onboarding Flow + +> **Related spec:** [spec/architecture.md](../../spec/architecture.md) | [spec/database.md](../../spec/database.md) + +## Overview + +First-time setup and migration flows for SimpleX Chat iOS. Covers app initialization, profile creation, server operator selection, notification configuration, and database import/export for device migration. The app uses a Haskell runtime for its core chat engine, with SQLite databases shared between the main app and the Notification Service Extension (NSE). + +## Prerequisites + +- Fresh install of SimpleX Chat from the App Store, or +- Existing install with database archive for import/migration +- iOS 15+ with App Group entitlement configured + +## Step-by-Step Processes + +### 1. App Initialization Sequence + +On every app launch, `SimpleXApp.init()` executes the following in order: + +``` +1. haskell_init() -- Start Haskell runtime system (GHC RTS) +2. UserDefaults.standard.register(defaults:) -- Set default preferences (appDefaults) +3. setGroupDefaults() -- Configure app group shared defaults +4. registerGroupDefaults() -- Register group container defaults +5. setDbContainer() -- Configure database paths in app group container +6. BGManager.shared.register() -- Register background task handlers +7. NtfManager.shared.registerCategories() -- Register notification action categories +``` + +Then in `ContentView.onAppear`: +- If no migration is in progress and authentication is set up, `initChatAndMigrate()` is called. +- This triggers `chatMigrateInit()` to initialize/migrate databases. +- Then `startChat()` is called to start the chat engine. + +### 2. Fresh Install -- Onboarding Steps + +Onboarding is managed by `OnboardingStage` enum and `OnboardingView`: + +**Step 1: SimpleX Info** (`step1_SimpleXInfo`) +1. `SimpleXInfo` view is presented. +2. Explains SimpleX's architecture: no user identifiers, E2E encryption, decentralized servers. +3. User taps "Create your profile" to proceed. + +**Step 2: Create Profile** (`step2_CreateProfile` -- now inline in step 1) +1. `CreateFirstProfile` view (embedded in the onboarding flow). +2. User enters display name (required). Full name is set to empty string. +3. Display name is validated via `mkValidName()` and `canCreateProfile()`. +4. On "Create": + ```swift + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat() + ``` +5. `apiCreateActiveUser(Profile(displayName:fullName:shortDescr:))` creates the user in the Haskell core. +6. `startChat()` initializes the chat engine. +7. Onboarding advances to `step3_ChooseServerOperators`. + +**Step 3: Choose Server Operators** (`step3_ChooseServerOperators`) +1. `OnboardingConditionsView` is presented (simplified conditions acceptance). +2. User reviews and accepts server operator conditions. +3. This configures which SMP/XFTP server operators to use. +4. Advances to `step4_SetNotificationsMode`. + +**Step 4: Set Notifications** (`step4_SetNotificationsMode`) +1. `SetNotificationsMode` view is presented. +2. Three options: + - **Instant**: Requires Apple Push Notification service. Registers device token via `apiRegisterToken(token:notificationMode:)`. + - **Periodic**: Uses iOS background app refresh. No push token needed. + - **Off**: No notifications. +3. For instant mode: `apiRegisterToken` sends `ChatCommand.apiRegisterToken(token:notificationMode:)` and receives `ChatResponse2.ntfTokenStatus(status)`. +4. On completion: `onboardingStageDefault.set(.onboardingComplete)`. + +**Onboarding Complete** (`onboardingComplete`) +1. `ChatListView` is shown. +2. Empty state displays "Add contact" prompt via `ChatHelp`. +3. If delivery receipts haven't been configured: `chatModel.setDeliveryReceipts = true` triggers a prompt. + +### 3. startChat() -- Chat Engine Startup + +Called after profile creation or on subsequent app launches: + +```swift +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { + 1. setNetworkConfig(getNetCfg()) -- Apply network configuration + 2. apiCheckChatRunning() -- Check if already running + 3. listUsers() -- Load all user profiles + 4. getUserChatData() -- Load chats, tags, address, TTL + 5. NtfManager.shared.setNtfBadgeCount(...) -- Set badge count + 6. refreshCallInvitations() -- Check pending call invitations + 7. apiGetNtfToken() -- Get notification token status + 8. apiStartChat() -- Start the Haskell chat engine + 9. registerToken(token:) -- Register push token if available + 10. ChatReceiver.shared.start() -- Start message receive loop +} +``` + +### 4. Database Setup + +**Location:** +- App group container (shared with NSE): determined by `dbContainerGroupDefault` +- Path prefix: `simplex_v1` (`DB_FILE_PREFIX`) +- Chat database: `simplex_v1_chat.db` (messages, contacts, groups, settings) +- Agent database: `simplex_v1_agent.db` (SMP connections, encryption keys, queues) + +**Initialization:** +- `chatMigrateInit(useKey:confirmMigrations:backgroundMode:)` in `SimpleXChat/API.swift`. +- Creates databases if they do not exist. +- Runs pending migrations with confirmation mode. +- Handles database encryption: + - If keychain storage enabled: generates random DB key on first run (`randomDatabasePassword()`). + - Stores key in keychain via `kcDatabasePassword`. + - `initialRandomDBPassphraseGroupDefault` tracks whether using auto-generated key. + +**Encryption:** +- Optional database encryption passphrase via `DatabaseEncryptionView`. +- `apiStorageEncryption(currentKey:newKey:)` changes encryption key. +- `testStorageEncryption(key:)` validates a key against the database. + +### 5. Database Export (Source Device) + +1. User navigates to Settings -> Database -> "Export database". +2. Chat must be stopped first for data consistency. +3. Calls `apiExportArchive(config: ArchiveConfig)`: + ```swift + func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core creates a ZIP archive containing both databases and file attachments. +5. Returns any non-fatal `[ArchiveError]` (e.g., file access issues). +6. User transfers the archive to the new device via AirDrop, file share, etc. + +### 6. Database Import (Destination Device) + +1. On new device: during onboarding or Settings -> Database -> "Import database". +2. User selects the archive file. +3. Calls `apiImportArchive(config: ArchiveConfig)`: + ```swift + func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core extracts the archive, replacing local databases. +5. Returns any non-fatal `[ArchiveError]`. +6. Chat engine is restarted with the imported data. +7. All contacts, groups, messages, and settings are restored. + +### 7. In-App Device Migration + +An alternative to manual export/import using direct device-to-device transfer. + +**Source device** (`MigrateFromDevice` view): +1. User navigates to Settings -> Database -> "Migrate to another device". +2. App creates a temporary database and uploads archive via XFTP standalone file. +3. Generates a migration link containing the file URL and encryption key. +4. Displays QR code / share link for the destination device. + +**Destination device** (`MigrateToDevice` view): +1. On new device: onboarding detects migration state or user selects "Migrate". +2. Scans/pastes the migration link. +3. `downloadStandaloneFile(user:url:file:ctrl:)` downloads the archive from XFTP. +4. `standaloneFileInfo(url:ctrl:)` validates the file metadata. +5. Archive is imported, databases are restored. +6. `chatInitTemporaryDatabase(url:key:confirmation:)` may be used for temporary DB operations during migration. +7. Chat engine starts with the migrated data. + +If migration is interrupted: +- `chatModel.migrationState` preserves state across app restarts. +- On next launch, `ContentView.onAppear` detects pending migration and resumes. + +### 8. Additional Profile Creation (Multi-Account) + +1. From `UserPicker` (profile switcher) -> "Add profile". +2. `CreateProfile` view is presented (distinct from `CreateFirstProfile`). +3. User enters display name and optional bio (max 160 bytes JSON-encoded, `MAX_BIO_LENGTH_BYTES`). +4. `apiCreateActiveUser(profile)` creates additional user. +5. `listUsers()` and `getUserChatData()` refresh the model. +6. No onboarding steps -- goes directly to chat list. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `OnboardingStage` | `Shared/Views/Onboarding/OnboardingView.swift` | Enum: `step1_SimpleXInfo`, `step2_CreateProfile`, `step3_ChooseServerOperators`, `step4_SetNotificationsMode`, `onboardingComplete` | +| `Profile` | `SimpleXChat/ChatTypes.swift` | `displayName`, `fullName`, `image`, `shortDescr` | +| `User` | `SimpleXChat/ChatTypes.swift` | Full user model with profile, userId, and settings | +| `ArchiveConfig` | `SimpleXChat/APITypes.swift` | Configuration for database export/import | +| `DBMigrationResult` | `SimpleXChat/API.swift` | Result of database migration: `.ok`, `.errorNotADatabase`, `.errorKeychain`, etc. | +| `MigrationConfirmation` | `SimpleXChat/API.swift` | Migration confirmation mode: `.error`, `.yesUp`, `.yesUpDown` | +| `DeviceToken` | `SimpleXChat/ChatTypes.swift` | Apple push notification device token | +| `NtfTknStatus` | `SimpleXChat/ChatTypes.swift` | Notification token status: registered, active, expired, etc. | +| `NotificationsMode` | `SimpleXChat/ChatTypes.swift` | `.off`, `.periodic`, `.instant` | +| `MigrationFileLinkData` | Used in standalone file transfers for device migration | +| `AppChatState` | `SimpleXChat/` | Shared state: `.active`, `.stopped`, `.suspended` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `DBMigrationResult.errorNotADatabase` | Wrong encryption key or corrupt DB | Show `DatabaseErrorView` with options | +| `DBMigrationResult.errorKeychain` | Keychain access failed | Show error, offer to re-enter passphrase | +| `DBMigrationResult.errorMigration` | Schema migration failure | Show error with migration details | +| `duplicateUserError` | Display name already in use | `UserProfileAlert.duplicateUserError` | +| `invalidDisplayNameError` | Invalid characters in display name | `UserProfileAlert.invalidDisplayNameError` | +| `createUserError` | Core failed to create user | Alert with error details | +| `invalidNameError(validName)` | Name needs normalization | Alert suggesting the valid name | +| Archive import errors | Missing files, version mismatch | Non-fatal `[ArchiveError]` displayed | +| Migration interrupted | Network failure, app killed | State preserved in `chatModel.migrationState`, resumed on next launch | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/SimpleXApp.swift` | App entry point: `haskell_init`, defaults registration, DB container setup, BG tasks | +| `Shared/AppDelegate.swift` | Push notification registration, URL handling | +| `Shared/ContentView.swift` | Root view: authentication, onboarding routing, chat initialization | +| `Shared/Views/Onboarding/OnboardingView.swift` | Onboarding step router, `OnboardingStage` enum | +| `Shared/Views/Onboarding/SimpleXInfo.swift` | Step 1: Privacy architecture explanation | +| `Shared/Views/Onboarding/CreateProfile.swift` | Profile creation: `CreateProfile` (additional) and `CreateFirstProfile` (onboarding) | +| `Shared/Views/Onboarding/ChooseServerOperators.swift` | Step 3: Server operator conditions | +| `Shared/Views/Onboarding/SetNotificationsMode.swift` | Step 4: Notification mode selection | +| `Shared/Views/Onboarding/CreateSimpleXAddress.swift` | Optional address creation during onboarding | +| `Shared/Views/Onboarding/HowItWorks.swift` | Educational content about SimpleX protocol | +| `Shared/Views/Migration/MigrateFromDevice.swift` | Source device migration UI | +| `Shared/Views/Migration/MigrateToDevice.swift` | Destination device migration UI | +| `Shared/Views/Database/DatabaseView.swift` | Database management: export, import, encryption | +| `Shared/Views/Database/DatabaseEncryptionView.swift` | Database passphrase management | +| `Shared/Views/Database/DatabaseErrorView.swift` | Database error recovery UI | +| `Shared/Views/Database/MigrateToAppGroupView.swift` | Legacy migration from Documents to App Group container | +| `Shared/Model/SimpleXAPI.swift` | `startChat`, `apiCreateActiveUser`, `apiExportArchive`, `apiImportArchive`, `apiRegisterToken` | +| `SimpleXChat/API.swift` | `chatMigrateInit`, `chatInitTemporaryDatabase`, low-level DB initialization | +| `SimpleXChat/FileUtils.swift` | DB file paths, constants (`DB_FILE_PREFIX`, `CHAT_DB`, `AGENT_DB`) | +| `SimpleXChat/AppGroup.swift` | App group container configuration | +| `SimpleXChat/KeyChain.swift` | Keychain access for DB passphrase and app passwords | +| `Shared/Model/BGManager.swift` | Background task registration and scheduling | +| `Shared/Model/NtfManager.swift` | Notification management and badge counts | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: architecture and capabilities +- `apps/ios/product/flows/connection.md` -- After onboarding, user establishes first connections +- `apps/ios/product/flows/messaging.md` -- Messaging starts after profile creation diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md new file mode 100644 index 0000000000..04cf97a6a7 --- /dev/null +++ b/apps/ios/product/gaps.md @@ -0,0 +1,61 @@ +# SimpleX Chat iOS -- Known Gaps & Recommendations + +> Aggregation of `[GAP]` and `[REC]` annotations discovered during specification analysis. Organized by product area. +> +> **Related spec:** [spec/README.md](../spec/README.md) + +--- + +## UI: Error Feedback + +### GAP: No user-visible error on FFI command failure +**Source:** [spec/architecture.md](../spec/architecture.md) +API calls via `chatApiSendCmd` return `APIResult` which can be `.error(ChatError)`. Not all error cases surface user-visible feedback in the UI. + +**REC:** Audit all `chatApiSendCmd` call sites and ensure `.error` cases show appropriate alerts or banners. + +--- + +## UI: Loading States + +### GAP: No loading indicator during initial chat list population +**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md) +When `ChatModel.chatInitialized` transitions to `true`, the chat list appears fully formed. There is no intermediate loading state for users with large numbers of chats. + +**REC:** Add a progress indicator during `apiGetChats` for users with 100+ conversations. + +--- + +## Flows: Group Lifecycle + +### GAP: Bulk member role change — API supports batch but UI uses single-member calls +**Source:** [spec/api.md](../spec/api.md) +`APIMembersRole` accepts `NonEmpty GroupMemberId`, supporting batch role changes at the API level. However, the iOS UI (`GroupMemberInfoView.swift`) currently invokes it with a single member at a time. + +**REC:** Expose batch role change in the UI for group admins managing large groups. + +--- + +## Security + +### GAP: Database passphrase not enforced by default +**Source:** [spec/database.md](../spec/database.md) +Database encryption is optional and requires the user to manually set a passphrase. New installations start with an unencrypted database. + +**REC:** Consider prompting users to set a database passphrase during onboarding, especially on devices without hardware encryption. + +### GAP: No forward secrecy indicator in UI +**Source:** [product/glossary.md](glossary.md) +While the double-ratchet protocol provides forward secrecy, there is no UI indicator showing whether a specific conversation has achieved forward secrecy (i.e., completed initial key exchange ratcheting). + +**REC:** Add a security indicator in contact/group info showing ratchet state. + +--- + +## Documentation + +### GAP: Haskell Store layer not fully specified +**Source:** [spec/database.md](../spec/database.md) +The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types. + +**REC:** Expand database spec with key Store function signatures as the specification matures. diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md new file mode 100644 index 0000000000..0353c8f606 --- /dev/null +++ b/apps/ios/product/glossary.md @@ -0,0 +1,235 @@ +# SimpleX Chat iOS -- Glossary + +> SimpleX Chat iOS domain glossary. Defines all domain terms used in SimpleX Chat with links to relevant specifications and source code. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Protocols & Cryptography](#protocols--cryptography) +2. [Core Data Types](#core-data-types) +3. [Commands & Events](#commands--events) +4. [Connection & Identity](#connection--identity) +5. [Messaging Features](#messaging-features) +6. [Calling & Media](#calling--media) +7. [Notifications & Background](#notifications--background) +8. [Application Architecture](#application-architecture) +9. [Configuration & Preferences](#configuration--preferences) + +--- + +## Protocols & Cryptography + +### SMP (Simplex Messaging Protocol) +The core messaging protocol used for asynchronous message delivery through relay servers. Each conversation uses separate unidirectional queues, and sender and receiver queues have no shared identifier. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/simplex-messaging.md`, implementation `simplexmq/src/Simplex/Messaging/Protocol.hs`* + +### SMP Server +A relay server that stores and forwards encrypted messages between parties. Users can configure custom SMP servers or use defaults. Servers cannot see message contents or correlate senders with receivers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### XFTP (eXtended File Transfer Protocol) +A protocol for transferring large files (up to 1GB) through relay servers. Files are encrypted, split into chunks, and uploaded to XFTP servers. Recipients download and reassemble chunks independently. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/xftp.md`, implementation `simplexmq/src/Simplex/FileTransfer/Protocol.hs`; chat-level integration `../../src/Simplex/Chat/Files.hs`* + +### XFTP Server +A relay server that stores encrypted file chunks for asynchronous file transfer. Like SMP servers, users can configure custom XFTP servers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### SMP Agent +The lower-level agent library (in [simplexmq](https://github.com/simplex-chat/simplexmq)) that manages SMP connections, queue creation/rotation, duplex connection establishment, message delivery, and the double-ratchet encryption protocol. The chat application layer communicates with the agent via its functional API. *See: protocol spec `simplexmq/protocol/agent-protocol.md`, implementation `simplexmq/src/Simplex/Messaging/Agent.hs`; chat-level integration `../../src/Simplex/Chat/Controller.hs`* + +### Double Ratchet +The key agreement protocol used for E2E encryption. Provides forward secrecy and break-in recovery by deriving new encryption keys for each message. Based on the Signal protocol's double-ratchet algorithm, augmented with post-quantum KEM (PQDR). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Post-Quantum Encryption +Optional quantum-resistant key exchange (PQ) available for direct chats. Uses a hybrid scheme combining classical X25519 with Streamlined NTRU-Prime 761 (sntrup761) KEM. The hybrid secret is SHA3-256(DH_secret || KEM_shared_secret). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs`; Swift types `SimpleXChat/ChatTypes.swift` (PQEncryption, PQSupport)* + +### E2E Encryption +End-to-end encryption ensuring that only the communicating parties can read message contents. Neither SMP relay servers nor any network observer can decrypt messages. All SimpleX Chat messages are E2E encrypted by default using the double-ratchet protocol. *See: `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` (ratchet implementation), `simplexmq/src/Simplex/Messaging/Agent/Protocol.hs` (E2E message envelopes)* + +### Forward Secrecy +A property of the double-ratchet protocol ensuring that compromise of current encryption keys does not compromise past session keys. Each message uses a derived key that is deleted after use. *See: `simplexmq/protocol/pqdr.md`, `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Chat Protocol (x-events) +The chat-level protocol defining message envelopes and content types exchanged between chat participants. Includes x-events (XMsgNew, XMsgUpdate, XMsgDel, XCallInv, XFileCancel, XGrpMemNew, etc.), MsgContent (text, image, video, voice, file, link), and message encoding (Binary/JSON). This is distinct from the lower-level SMP transport protocol. *See: `../../src/Simplex/Chat/Protocol.hs`* + +### Security Code +A hash of the shared encryption session displayed as a numeric code and QR code. Contacts can compare security codes out-of-band to verify they have an uncompromised E2E session. *See: `Shared/Views/Chat/VerifyCodeView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIVerifyContact)* + +--- + +## Core Data Types + +### ChatItem +The fundamental unit of content in a conversation. Represents a single message, event, call record, or system notification within a chat. Each ChatItem has direction (sent/received), content, metadata, and optional quoted context. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatItem), `SimpleXChat/ChatTypes.swift`* + +### ChatInfo +A type-safe wrapper identifying a conversation and its metadata. Variants: DirectChat (1:1 with Contact), GroupChat (with GroupInfo), LocalChat (note folder), ContactRequest, ContactConnection. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatInfo), `SimpleXChat/ChatTypes.swift`* + +### CIContent +The content payload of a ChatItem. Differentiates sent vs. received content types: message content (text/image/file/voice/link), deletion markers, call records, group events, and feature preference changes. *See: `../../src/Simplex/Chat/Messages/CIContent.hs` (data CIContent)* + +### User +A local user profile within the app. Each user has an independent set of contacts, groups, and connections. Multiple users can exist in one app installation. Fields include userId, profile, display name, and optional view password hash for hidden profiles. *See: `../../src/Simplex/Chat/Types.hs` (data User), `Shared/Model/ChatModel.swift`* + +### Contact +A remote party with whom the user has an established E2E encrypted connection. Stores the contact's profile, local alias, connection status, feature preferences, and UI settings. *See: `../../src/Simplex/Chat/Types.hs` (data Contact), `SimpleXChat/ChatTypes.swift`* + +### GroupInfo +Metadata for a group conversation including group profile, member count, preferences, and membership status. Contains the user's own membership record as a GroupMember. *See: `../../src/Simplex/Chat/Types.hs` (data GroupInfo)* + +### GroupMember +A participant in a group conversation. Each member has a role, status, profile, and optionally a direct connection. The user's own membership is also represented as a GroupMember within GroupInfo. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMember)* + +### Connection +A low-level SMP agent connection between two parties. Each connection has a status (new, joined, ready, deleted), an agent connection ID, and is associated with a specific contact or group member. *See: `../../src/Simplex/Chat/Types.hs` (data Connection)* + +### ConnStatus +The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoined (joined, handshake in progress), ConnReady (fully established), ConnDeleted (terminated). *See: `../../src/Simplex/Chat/Types.hs` (data ConnStatus)* + +### ContactStatus +The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)* + +### GroupMemberRole +Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver. Roles determine permissions for sending messages, managing members, and moderating content. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole)* + +### GroupMemberStatus +The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)* + +### FileTransfer +Represents an in-progress or completed file transfer. Variants: FTSnd (sending, with metadata and per-recipient transfer records) and FTRcv (receiving). Tracks protocol (SMP inline or XFTP), progress, and encryption parameters. *See: `../../src/Simplex/Chat/Types.hs` (data FileTransfer)* + +### ChatTag +A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`* + +--- + +## Commands & Events + +### ChatCommand +A sum type representing all commands the UI can send to the chat controller. Examples: APISendMessages, APIGetChat, APIConnect, APINewGroup, APIDeleteChatItem. Commands are serialized and dispatched through the FFI bridge. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatCommand)* + +### ChatResponse +A sum type representing synchronous responses from the chat controller to the UI after processing a ChatCommand. Examples: CRActiveUser, CRNewChatItems, CRChatItemUpdated. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatResponse)* + +### ChatEvent +A sum type representing asynchronous events pushed from the chat controller to the UI. These are unsolicited notifications about state changes: incoming messages, connection status changes, call invitations, etc. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatEvent)* + +### ChatError +Error types returned by the chat controller. Variants: ChatError (application-level), ChatErrorAgent (SMP agent errors), ChatErrorStore (database errors), ChatErrorRemoteHost (remote desktop errors). *See: `../../src/Simplex/Chat/Controller.hs` (data ChatError)* + +--- + +## Connection & Identity + +### SimpleX Address +A long-lived contact address that others can use to send connection requests. Unlike one-time invitation links, an address can be reused by multiple contacts. The user can accept or reject each incoming request. *See: `Shared/Views/UserSettings/UserAddressView.swift`, `../../src/Simplex/Chat/Controller.hs` (APICreateMyAddress)* + +### Contact Link +A one-time or reusable URI that initiates a contact connection. When scanned or opened, it triggers the SMP handshake to establish an E2E encrypted channel between two parties. *See: `Shared/Views/NewChat/NewChatView.swift`* + +### Group Link +A shareable URI that allows new members to join a group. The link connects to the group host, who then introduces the new member to existing members. Configurable with a default member role. *See: `Shared/Views/Chat/Group/GroupLinkView.swift`, `../../src/Simplex/Chat/Types.hs` (data GroupLink)* + +### Short Link +A compact version of SimpleX contact or group links, using a shorter URI format for easier sharing. Contains encoded connection parameters with reduced character length. *See: `../../src/Simplex/Chat/Controller.hs`* + +### Incognito Mode +A privacy feature that generates a random profile (display name and avatar) for each new contact connection. The real user profile is never shared with incognito contacts. Can be toggled per-connection at invitation time. *See: `Shared/Views/UserSettings/IncognitoHelp.swift`, `../../src/Simplex/Chat/ProfileGenerator.hs`* + +### Hidden Profile +A user profile protected by a separate password. Hidden profiles do not appear in the user picker or profile list. To access a hidden profile, the user enters its password in the search field of the user picker. *See: `Shared/Views/UserSettings/HiddenProfileView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIHideUser)* + +--- + +## Messaging Features + +### Delivery Receipt +A confirmation that a message was successfully delivered to the recipient's device. Displayed as a double-check indicator on sent messages. Can be enabled or disabled per contact or globally. *See: `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift`, `../../src/Simplex/Chat/Controller.hs`* + +### Read Receipt +An indicator that a recipient has viewed a received message. Currently not implemented as a separate feature; delivery receipts serve as the primary delivery confirmation. *See: `Shared/Views/UserSettings/PrivacySettings.swift`* + +### Timed Message +A message with a configurable time-to-live (TTL). After the TTL expires, the message is automatically deleted from both sender and recipient devices. The TTL is set as a chat feature preference. Also referred to as a disappearing message. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Disappearing Message +Synonym for Timed Message. A message that self-destructs after a configured duration. The timer starts when the message is read by the recipient. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Message Integrity +Verification that messages are received in order and without gaps. The system detects skipped messages and decryption failures, displaying integrity error indicators in the chat. *See: `Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +### Decryption Error +An error occurring when a received message cannot be decrypted, typically due to ratchet synchronization issues. The UI displays a specific error view with recovery options. *See: `Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +--- + +## Calling & Media + +### CallKit +Apple's framework for integrating VoIP calls with the native iOS call UI. SimpleX Chat uses CallKit to display incoming calls on the lock screen, support call answering from the system UI, and manage audio sessions. *See: `Shared/Views/Call/CallController.swift`, `Shared/Views/Call/CallManager.swift`* + +### WebRTC +The real-time communication framework used for audio/video calls. SimpleX Chat wraps WebRTC in an E2E encrypted layer, with signaling performed through the existing SMP message channel rather than a central server. *See: `Shared/Views/Call/WebRTC.swift`, `Shared/Views/Call/WebRTCClient.swift`* + +### ICE Server +An Interactive Connectivity Establishment server used by WebRTC to discover network paths between call participants. SimpleX Chat supports configuring custom ICE servers. *See: `Shared/Views/UserSettings/RTCServers.swift`, `SimpleXChat/CallTypes.swift`* + +### TURN Server +A Traversal Using Relays around NAT server that relays WebRTC media when direct peer-to-peer connection is not possible. A specific type of ICE server. SimpleX Chat allows configuring custom TURN servers for call relay. *See: `Shared/Views/UserSettings/RTCServers.swift`* + +### RcvCallInvitation +An in-memory data structure representing an incoming call invitation. Contains the calling contact, call type (audio/video), encryption keys, and shared key for the WebRTC session. Not persisted to database. *See: `../../src/Simplex/Chat/Call.hs` (data RcvCallInvitation)* + +--- + +## Notifications & Background + +### Notification Service Extension (NSE) +An iOS app extension that processes incoming push notifications while the main app is not running. The NSE starts a temporary chat controller, decrypts the incoming message, and displays a notification with the message preview. *See: `SimpleX NSE/NotificationService.swift`, `SimpleX NSE/NSEAPITypes.swift`* + +### Background Task +An iOS background execution context used for periodic message fetching when instant notifications are not enabled. Managed by BGManager to check for new messages at system-determined intervals. *See: `Shared/Model/BGManager.swift`* + +--- + +## Application Architecture + +### chat_ctrl +The opaque C pointer to the Haskell chat controller, obtained via FFI initialization. All chat operations are dispatched through this controller handle. The main app and NSE maintain separate chat_ctrl instances. *See: `SimpleXChat/API.swift` (chatController, getChatCtrl)* + +### ComposeState +A Swift struct holding the current state of the message composition area. Tracks the message text, parsed markdown, preview, attached media, editing context, quote context, and voice recording state. *See: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (struct ComposeState)* + +### ChatModel +The central observable model object for the iOS app. Holds all reactive state: current user, chat list, active chat, call state, app preferences, and navigation state. Published properties drive SwiftUI view updates. *See: `Shared/Model/ChatModel.swift` (class ChatModel)* + +### ItemsModel +An observable model managing the list of ChatItems displayed in a conversation view. Handles item loading, pagination, merging of new items, and secondary chat filtering. *See: `Shared/Model/ChatModel.swift` (class ItemsModel)* + +### AppTheme +An observable object encapsulating the current visual theme: name, base theme, color overrides, app-specific colors, and wallpaper configuration. Shared as an environment object across the SwiftUI view hierarchy. *See: `Shared/Theme/Theme.swift` (class AppTheme)* + +--- + +## Configuration & Preferences + +### FeaturePreference +A type class (Haskell) / protocol pattern representing a user's preference for a specific chat feature (e.g., timed messages, voice messages, calls). Each preference has an allow/enable setting and optional parameters. Feature preferences are negotiated between contacts. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (class FeatureI, type FeaturePreference)* + +### ChatSettings +Per-chat configuration including notification mode (all/mentions/off), send receipts toggle, favorite flag, and tag assignments. Stored per contact and per group. *See: `../../src/Simplex/Chat/Types.hs` (data ChatSettings)* + +### UserDefaults / GroupDefaults +iOS persistent key-value storage for app preferences. GroupDefaults (UserDefaults with the app group suite name) is shared between the main app and the NSE extension. Stores settings like notification mode, appearance preferences, and runtime flags. *See: `SimpleXChat/AppGroup.swift` (groupDefaults)* + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Concept index: [concepts.md](concepts.md) +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell chat protocol (x-events): `../../src/Simplex/Chat/Protocol.hs` +- Haskell messages: `../../src/Simplex/Chat/Messages.hs` +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- simplexmq library (SMP, XFTP, Agent, encryption): [github.com/simplex-chat/simplexmq](https://github.com/simplex-chat/simplexmq) diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md new file mode 100644 index 0000000000..b41792898b --- /dev/null +++ b/apps/ios/product/rules.md @@ -0,0 +1,119 @@ +# SimpleX Chat iOS -- Business Rules + +> Business invariants enforced by the SimpleX Chat iOS app and Haskell core. Each rule states the invariant, where it is enforced, and links to the relevant spec. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) | [spec/state.md](../spec/state.md) + +--- + +## Security & Privacy + +### RULE-01: No user identifiers +**Rule:** The system MUST NOT assign, generate, or expose any persistent user identifier (phone number, email, username, UUID) that could be used to correlate a user across conversations. +**Enforced by:** SMP protocol design in simplexmq library; each connection uses independent unidirectional queues with no shared identifier. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-02: End-to-end encryption on all messages +**Rule:** All message content MUST be encrypted end-to-end using double-ratchet (with optional post-quantum KEM). The SMP server MUST NOT have access to plaintext. +**Enforced by:** simplexmq library (`Simplex.Messaging.Crypto.Ratchet`); encryption happens before `chat_send_cmd_retry` FFI call. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-03: Database encryption at rest +**Rule:** Both SQLite databases (chat and agent) MUST be encrypted with SQLCipher when the user sets a database passphrase. +**Enforced by:** `chat_migrate_init_key` in Haskell core via SQLCipher; `DatabaseEncryptionView.swift` in UI. +**Spec:** [spec/database.md](../spec/database.md) + +### RULE-04: Local authentication before content access +**Rule:** When app lock is enabled, the app MUST authenticate the user (Face ID, Touch ID, or passcode) before displaying any chat content. +**Enforced by:** `LocalAuthView.swift`, `ContentView.swift` (`contentViewAccessAuthenticated` guard on `ChatModel`). +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-05: Incognito profiles are per-connection +**Rule:** When incognito mode is used for a connection, the generated random profile MUST be unique to that connection and MUST NOT be reused across connections. +**Enforced by:** `ProfileGenerator.hs` generates fresh profile per connection; stored on the connection entity. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Message Integrity + +### RULE-06: Message order preservation +**Rule:** Messages within a single connection MUST be displayed in the order determined by the SMP agent's sequence numbers, not by local timestamps. +**Enforced by:** `Store/Messages.hs` (`createNewChatItem` uses agent-assigned ordering); `ItemsModel` in `ChatModel.swift` preserves this order. +**Spec:** [spec/state.md](../spec/state.md) + +### RULE-07: Edited messages retain history +**Rule:** When a message is edited, the previous version MUST be preserved in `chat_item_versions` and accessible via the item info view. +**Enforced by:** `Controller.hs` (`APIUpdateChatItem`); `Store/Messages.hs` (`updateChatItem` creates version record); `ChatItemInfoView.swift` displays history. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-08: Deleted messages respect deletion mode +**Rule:** `CIDeleteMode.cidmBroadcast` sends deletion to recipient; `cidmInternal` only deletes locally. Moderation deletion (`cidmInternalMark`) marks the item but retains a placeholder. +**Enforced by:** `Controller.hs` (`APIDeleteChatItem` checks `CIDeleteMode`); `MarkedDeletedItemView.swift` renders moderation placeholders. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-09: Timed messages auto-delete after TTL +**Rule:** Messages with a TTL MUST be automatically deleted from local storage after the configured time-to-live expires. +**Enforced by:** `Controller.hs` (background task scheduling); `Store/Messages.hs` (TTL-based cleanup). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Group Integrity + +### RULE-10: Role hierarchy enforcement +**Rule:** A member can only modify members with strictly lower roles. Owner > Admin > Moderator > Member > Observer. +**Enforced by:** `Controller.hs` (`APIMembersRole` validates role hierarchy); `GroupMemberInfoView.swift` restricts available actions in UI. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-11: Group creator is always owner +**Rule:** The user who creates a group MUST be assigned the `GROwner` role and cannot be demoted. +**Enforced by:** `Controller.hs` (`APINewGroup`); `Store/Groups.hs` (`createNewGroup` assigns owner role). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-12: Group link role assignment +**Rule:** Members joining via group link MUST receive the role configured on the link (default: `GRMember`). Only admins and owners can create group links. +**Enforced by:** `Controller.hs` (`APICreateGroupLink` takes `memberRole` parameter); `GroupLinkView.swift` UI restricts to admin+. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## File Transfer + +### RULE-13: File size limits +**Rule:** Files up to 1GB are transferred via XFTP. The system MUST reject files exceeding the configured maximum. +**Enforced by:** Haskell core (`Files.hs` checks file size); XFTP protocol enforces chunk limits. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +### RULE-14: File encryption at rest +**Rule:** When `privacyEncryptLocalFiles` is enabled, downloaded files MUST be encrypted locally using AES with per-file random key/nonce stored in `CryptoFile`. +**Enforced by:** `CryptoFile.swift` (`encryptCryptoFile`, `decryptCryptoFile`); `Library/Commands.hs` uses `CryptoFileArgs` for file encryption. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +--- + +## Notification Delivery + +### RULE-15: Notification preview respects privacy setting +**Rule:** Notification content MUST respect `NotificationPreviewMode`: `.message` shows full content, `.contact` shows sender only, `.hidden` shows generic alert. +**Enforced by:** `Notifications.swift` (notification content creation checks `ntfPreviewModeGroupDefault`); `NotificationService.swift` (NSE content generation). +**Spec:** [spec/services/notifications.md](../spec/services/notifications.md) + +### RULE-16: NSE database coordination +**Rule:** The NSE and main app MUST NOT write to the database simultaneously. File locks coordinate access. +**Enforced by:** `chat_close_store` / `chat_reopen_store` FFI calls; NSE uses short-lived database sessions. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +--- + +## Call Integrity + +### RULE-17: Call encryption key exchange +**Rule:** WebRTC call encryption keys MUST be negotiated over the existing E2E encrypted SMP channel, not through any external signaling server. +**Enforced by:** `ActiveCallView.swift` sends call signaling via `apiSendCallInvitation`/`apiSendCallAnswer` which use SMP; `Call.hs` defines call protocol. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) + +### RULE-18: CallKit region restriction +**Rule:** CallKit MUST be disabled in regions where it is restricted (China). The app uses in-app call UI as fallback. +**Enforced by:** `CallController.swift` checks `useCallKit()` based on region; `ActiveCallView.swift` provides fallback UI. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) diff --git a/apps/ios/product/views/call.md b/apps/ios/product/views/call.md new file mode 100644 index 0000000000..f32f7ec243 --- /dev/null +++ b/apps/ios/product/views/call.md @@ -0,0 +1,122 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. Supports CallKit integration for native iOS call UI, picture-in-picture for video calls, audio device selection, and collapsible call overlay. + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallView` banner appears at top of screen; or native CallKit UI if enabled +- **Presented by**: `ActiveCallView` is overlaid on the main app view when `chatModel.activeCall` is set +- **Collapsible**: Call view can be collapsed via `chatModel.activeCallViewIsCollapsed` to return to chat while call continues +- **Dismiss**: Call ends when user taps end button or remote party disconnects + +## Page Sections + +### Incoming Call Banner (`IncomingCallView`) + +Displayed as an overlay banner when `CallController.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| Profile avatar | User profile image (shown when multiple profiles exist) | +| Call type icon | `video.fill` (green) for video calls, `phone.fill` (green) for audio | +| Call type text | "Audio call" or "Video call" with caller info | +| Caller profile | `ProfilePreview` showing caller name and image | +| Reject button | Red `phone.down.fill` icon -- ends the invitation | +| Ignore button | Neutral `multiply` icon -- dismisses the banner without rejecting | +| Accept button | Green `checkmark` icon -- accepts the call; if another call is active, ends it first | + +Sound: Ringtone plays via `SoundPlayer.startRingtone()` while banner is visible (unless call view is already showing). + +### Active Call View (`ActiveCallView`) + +Full-screen overlay with black background: + +| Element | Description | +|---|---| +| Remote video | Full-screen `CallViewRemote` showing remote party's camera feed; tap toggles between `scaleAspectFill` and `scaleAspectFit` | +| Local video preview | Small floating `CallViewLocal` in top-right corner (30% width); shows local camera with rounded corners | +| Call overlay | `ActiveCallOverlay` with call controls (hidden when PiP is active for video calls) | +| Screen keep-on | `AppDelegate.keepScreenOn(true)` prevents screen dimming during calls | + +### Call Controls (`ActiveCallOverlay`) + +Bottom bar of the active call: + +| Control | Description | +|---|---| +| Mute toggle | Microphone on/off | +| Speaker toggle | Speaker/receiver switch | +| Camera switch | Front/back camera toggle (video calls) | +| Video toggle | Enable/disable video during call | +| End call | Red phone-down button to terminate | +| Audio device picker | `AudioDevicePicker` / `CallAudioDeviceManager` for selecting output (receiver, speaker, Bluetooth, AirPods) | + +### Picture-in-Picture (PiP) + +- When `pipShown == true` and call has video, the call overlay is hidden +- PiP window shows the remote video feed +- User can interact with the app normally while call continues + +### CallKit Integration + +Managed by `CallController`: + +| Feature | Description | +|---|---| +| Native incoming call UI | iOS system call screen for incoming calls (when CallKit is enabled) | +| Call history | Optionally shown in Phone app recents (`DEFAULT_CALL_KIT_CALLS_IN_RECENTS`) | +| System audio routing | CallKit manages audio session configuration | +| Lock screen answering | Call can be answered from lock screen via system UI | + +When CallKit is not used, the app falls back to `IncomingCallView` banner. + +### WebRTC Client + +| Component | Description | +|---|---| +| `WebRTCClient` | Manages peer connection, ICE candidates, media tracks | +| `WebRTC.swift` | Bridge between native code and WebRTC JavaScript via `WKWebView` | +| `CallViewRenderers` | `CallViewLocal` and `CallViewRemote` SwiftUI wrappers for video renderers | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Permissions required | Prompts for microphone (and camera for video) permissions on first call | +| Connecting | Call overlay shows connecting state; `SoundPlayer` plays connecting tone | +| WebRTC client creation | `createWebRTCClient()` called on appear and when `canConnectCall` changes | +| Call ended | `CallSoundsPlayer.vibrate(long: true)` on disconnect if was connected; audio session reset to `.soloAmbient` | +| Call failed | Call dismissed; WebRTC client cleaned up | +| No call invitation | `IncomingCallView` body is empty when no active invitation | + +## Audio Session Management + +- During call: Audio session configured for voice chat +- Camera permissions: `AVFoundation.AVCaptureDevice` authorization checked +- Audio device management: `CallAudioDeviceManager` handles routing changes and device enumeration +- Post-call cleanup: Audio session reverted to `.soloAmbient` + +## Related Specs + +- `spec/services/calls.md` -- Call service specification +- [Chat](chat.md) -- Call buttons in chat navigation bar +- [Contact Info](contact-info.md) -- Call buttons in contact info action row +- [Settings](settings.md) -- Call settings (CallKit, ICE servers, relay policy) + +## Source Files + +- `Shared/Views/Call/ActiveCallView.swift` -- Main active call view with video renderers and overlay +- `Shared/Views/Call/IncomingCallView.swift` -- Incoming call notification banner +- `Shared/Views/Call/CallController.swift` -- CallKit integration and call lifecycle management +- `Shared/Views/Call/CallManager.swift` -- Call state management and CXProvider delegate +- `Shared/Views/Call/CallAudioDeviceManager.swift` -- Audio device enumeration and routing +- `Shared/Views/Call/AudioDevicePicker.swift` -- Audio output device picker UI +- `Shared/Views/Call/WebRTC.swift` -- WebRTC signaling bridge via WKWebView +- `Shared/Views/Call/WebRTCClient.swift` -- WebRTC peer connection management +- `Shared/Views/Call/CallViewRenderers.swift` -- SwiftUI wrappers for local and remote video views +- `Shared/Views/Call/SoundPlayer.swift` -- Ringtone and call sound playback diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md new file mode 100644 index 0000000000..6c2d868d64 --- /dev/null +++ b/apps/ios/product/views/chat-list.md @@ -0,0 +1,113 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat app. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ContentView` as the default view when `chatModel.chatId == nil` +- **Navigation stack**: `NavStackCompat` wrapping `chatListView` with destination `chatView` +- **UserPicker sheet**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet, which links to `UserPickerSheetView` sub-sheets (address, preferences, profiles, current profile, use from desktop, settings) + +## Page Sections + +### Toolbar + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop) | +| Connection status indicator | Center (`SubsStatusIndicator`) | Shows server subscription status; taps navigate to `ServersSummaryView` | +| New chat button (pencil icon) | Trailing | Opens `NewChatSheet` modal | + +The toolbar supports two layout modes: +- **Standard (top)**: Navigation bar with `.topBarLeading`, `.principal`, `.topBarTrailing` placements +- **One-hand UI (bottom)**: Toolbar items placed in `.bottomBar` with the list vertically flipped via `scaleEffect(y: -1)` + +### Search Bar + +- Text field with magnifying glass icon +- When active, `searchMode = true` hides the navigation bar and shows inline search +- Filters chat list in real-time by contact/group name and message content +- Detects pasted SimpleX links (`searchShowingSimplexLink`) and offers to connect + +### Chat Filter Tabs (Tags) + +Managed by `ChatTagsModel` and `TagListView`: + +| Filter | PresetTag | Description | +|---|---|---| +| All | (none) | No filter, shows all chats | +| Unread | `.unread` | Chats with unread messages | +| Favorites | `.favorites` | User-favorited chats | +| Groups | `.groups` | Group conversations only | +| Contacts | `.contacts` | Direct contacts only | +| Business | `.business` | Business chat conversations | +| Notes | `.notes` | Notes to self | +| Group Reports | `.groupReports` | Moderation reports (non-collapsible) | +| Custom tags | `.userTag(ChatTag)` | User-created tags with custom names | + +### Chat Preview Rows + +Each row rendered by `ChatPreviewView` inside `ChatListNavLink`: + +| Element | Description | +|---|---| +| Avatar | Profile image or colored initials circle; online status indicator for contacts | +| Chat name | Display name (contact, group, or note-to-self) | +| Last message preview | Truncated text of most recent message; supports markdown rendering | +| Timestamp | Relative time of last activity (e.g., "2m", "1h", "Yesterday") | +| Unread badge | Numeric count badge for unread messages; distinct styling for mentions | +| Muted indicator | Bell-slash icon when notifications are muted | +| Pinned indicator | Pin icon for pinned chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +### Swipe Actions + +- **Trailing swipe**: Mute/unmute, pin/unpin, tag management +- **Leading swipe**: Mark as read/unread +- **Context menu** (long press): Full set of actions including delete, clear chat, toggle favorite + +### Floating Elements + +- **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode +- **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address + +### Pull-to-Refresh + +Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect servers?"). Uses additional traffic to force message delivery. + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | +| No chats | `ChatHelp` view displayed with onboarding guidance | +| Connection in progress | `ConnectProgressManager` overlay with connecting text | +| Search with no results | Empty list with no special empty-state view | + +## Related Specs + +- `spec/client/chat-list.md` -- Chat list feature specification +- `spec/state.md` -- Application state management +- [User Profiles](user-profiles.md) -- Profile switching from UserPicker +- [Settings](settings.md) -- Settings accessed via UserPicker +- [New Chat](new-chat.md) -- New chat sheet triggered from toolbar +- [Chat](chat.md) -- Navigated to when tapping a chat row + +## Source Files + +- `Shared/Views/ChatList/ChatListView.swift` -- Main view, toolbar, search, filter logic +- `Shared/Views/ChatList/ChatPreviewView.swift` -- Individual chat row rendering +- `Shared/Views/ChatList/ChatListNavLink.swift` -- Navigation link wrapper with swipe actions +- `Shared/Views/ChatList/TagListView.swift` -- Filter tab bar (preset + custom tags) +- `Shared/Views/ChatList/UserPicker.swift` -- User profile picker sheet +- `Shared/Views/ChatList/ChatHelp.swift` -- Empty-state help view +- `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering +- `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering +- `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md new file mode 100644 index 0000000000..57202846eb --- /dev/null +++ b/apps/ios/product/views/chat.md @@ -0,0 +1,165 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` +- **Presented by**: `NavStackCompat` destination from `ChatListView`, bound to `chatModel.chatId` +- **Back navigation**: Dismiss sets `chatModel.chatId = nil`, returning to chat list +- **Sub-navigation**: Info button navigates to `ChatInfoView` (contact) or `GroupChatInfoView` (group); member avatars navigate to `GroupMemberInfoView` + +## Page Sections + +### Navigation Bar + +Custom toolbar overlaying the chat with themed material background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list | +| Contact/Group avatar | Small profile image | +| Chat name | Display name; tappable to open info sheet | +| Encryption badge | Shows PQ (post-quantum) or standard E2E status | +| Call buttons | Audio and video call icons (direct chats only) | +| Search button | Toggles in-chat message search | +| Info button | Opens `ChatInfoView` or `GroupChatInfoView` | + +### Message List + +Rendered by `EndlessScrollView` with lazy loading and pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | Loads more items on scroll to top (`loadingTopItems`) and bottom (`loadingBottomItems`) | +| Merged items | Adjacent messages from the same sender are visually merged via `MergedItems` | +| Floating buttons | Scroll-to-bottom button with unread count; scroll-to-first-unread button | +| Date separators | Sticky date headers between messages from different days | +| Wallpaper | Themed background image with tint and opacity from `theme.wallpaper` | +| Content filter | Filter messages by type: `.images`, `.files`, `.links` | + +### Message Types + +Each type has a dedicated view in `Shared/Views/Chat/ChatItem/`: + +| Type | View | Description | +|---|---|---| +| Text | `MsgContentView` | Rendered with markdown (bold, italic, code, links, mentions) | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `FullScreenMediaView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback | +| Voice | `CIVoiceView` / `FramedCIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions | +| Link preview | `CILinkView` | URL preview card with title, description, image | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message | +| Forward | Opens `forwardedChatItems` sheet to pick destination chat | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode in compose bar (own messages, within edit window) | +| Delete | Delete for self or delete for everyone (with confirmation) | +| React | Opens emoji reaction picker | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward | +| Info | Shows delivery status and timestamps | + +Emoji reactions bar displayed below messages with reaction counts. + +### Compose Bar (`ComposeView`) + +| Element | Description | +|---|---| +| Text input | `NativeTextEditor` with markdown support and auto-growing height | +| Attachment button | Opens picker for images, videos, files, camera | +| Send button | Sends composed message; changes to voice record button when empty | +| Voice record | Hold-to-record with waveform preview; swipe-to-cancel | +| Reply quote | Shows quoted message above input when replying | +| Edit indicator | Shows "editing" label when editing a previous message | +| Link preview | Auto-generated preview card for detected URLs (`ComposeLinkView`) | +| Image/Video preview | Thumbnail strip for selected media (`ComposeImageView`) | +| File preview | File name and size for attached file (`ComposeFileView`) | +| Voice preview | Waveform of recorded voice message (`ComposeVoiceView`) | +| Live message | Real-time typing broadcast (optional, with alert on first use) | +| Context actions | `ContextContactRequestActionsView` for accepting/rejecting contact requests; `ContextPendingMemberActionsView` for pending group member actions | +| Commands menu | `CommandsMenuView` for bot/menu commands in chats with `menuCommands` | +| Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups | +| Profile picker | `ContextProfilePickerView` for choosing incognito/main profile | + +### Member Support Chat (Groups) + +For groups with member support enabled: +- `MemberSupportView` and `MemberSupportChatToolbar` shown as secondary chat within group +- `SecondaryChatView` for scoped group chat views (reports, member support) +- User knocking state: `userMemberKnockingTitleBar()` shown when user is pending admission + +## Loading / Error States + +| State | Behavior | +|---|---| +| Initial load | Messages load from `ItemsModel` with merged items; `allowLoadMoreItems` throttles pagination | +| Loading more (top) | `loadingTopItems` spinner at top of scroll view | +| Loading more (bottom) | `loadingBottomItems` spinner at bottom | +| Connection in progress | `ConnectProgressManager` shows connecting text below compose bar | +| Connecting text | "connecting..." label shown below message list when chat not yet ready | +| Send disabled | Compose bar shows `disabledText` reason when `userCantSendReason` is set | +| Empty chat | No messages placeholder (implicit -- empty scroll view) | + +## Related Specs + +- `spec/client/chat-view.md` -- Chat view feature specification +- `spec/client/compose.md` -- Compose bar specification +- [Chat List](chat-list.md) -- Parent navigation +- [Contact Info](contact-info.md) -- Info sheet for direct chats +- [Group Info](group-info.md) -- Info sheet for group chats +- [Call](call.md) -- Audio/video calls initiated from toolbar + +## Source Files + +- `Shared/Views/Chat/ChatView.swift` -- Main chat view, message list, navigation, state management +- `Shared/Views/Chat/ChatItemView.swift` -- Individual message item rendering dispatcher +- `Shared/Views/Chat/ComposeMessage/ComposeView.swift` -- Compose bar container +- `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` -- Send button and voice record +- `Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift` -- Text input with markdown +- `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` -- Image attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` -- File attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` -- Voice recording preview +- `Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift` -- Link preview generation +- `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` -- Reply/edit context display +- `Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift` -- Contact request accept/reject +- `Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift` -- Pending member actions +- `Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift` -- Profile picker for incognito +- `Shared/Views/Chat/ChatItem/FramedItemView.swift` -- Framed message bubble rendering +- `Shared/Views/Chat/ChatItem/MsgContentView.swift` -- Text message content with markdown +- `Shared/Views/Chat/ChatItem/CIImageView.swift` -- Image message view +- `Shared/Views/Chat/ChatItem/CIVideoView.swift` -- Video message view +- `Shared/Views/Chat/ChatItem/CIVoiceView.swift` -- Voice message view +- `Shared/Views/Chat/ChatItem/CIFileView.swift` -- File message view +- `Shared/Views/Chat/ChatItem/CILinkView.swift` -- Link preview view +- `Shared/Views/Chat/ChatItem/EmojiItemView.swift` -- Large emoji view +- `Shared/Views/Chat/ChatItem/CICallItemView.swift` -- Call event view +- `Shared/Views/Chat/ChatItem/CIEventView.swift` -- Group/system event view +- `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` -- Feature change notification +- `Shared/Views/Chat/ChatItem/CIMetaView.swift` -- Timestamp and delivery status +- `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` -- Fullscreen image/video viewer +- `Shared/Views/Chat/ChatItem/AnimatedImageView.swift` -- Animated GIF rendering +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention autocomplete +- `Shared/Views/Chat/Group/MemberSupportView.swift` -- Member support scoped chat +- `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` -- Support chat toolbar +- `Shared/Views/Chat/Group/SecondaryChatView.swift` -- Secondary scoped chat view diff --git a/apps/ios/product/views/contact-info.md b/apps/ios/product/views/contact-info.md new file mode 100644 index 0000000000..5223bfcae4 --- /dev/null +++ b/apps/ios/product/views/contact-info.md @@ -0,0 +1,154 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings, and perform destructive actions like blocking or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` + - Security code verification -> `VerifyCodeView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar; tappable | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field (`aliasTextFieldFocused`) for setting a local-only name visible only on this device. Not shared with the contact. + +### Action Buttons + +Horizontal row of quick-action buttons (width divided by 4): + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` to search messages in chat | +| Audio call | Initiate audio call (`AudioCallButton`) | +| Video call | Initiate video call (`VideoButton`) | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +Call buttons check `connectionStats` and show alerts if connection state prevents calling. + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito): + +| Element | Description | +|---|---| +| "Your random profile" label | Shows the incognito display name used for this contact | + +### Connection Settings Section + +| Element | Condition | Description | +|---|---|---| +| Verify security code | `connectionCode` available | Navigate to `VerifyCodeView` for QR-based code verification | +| Contact preferences | Always | Navigate to `ContactPreferencesView` | +| Send receipts | Always | Toggle: yes / no / default(yes) / default(no) | +| Synchronize connection | `ratchetSyncAllowed` | Fix encryption ratchet desynchronization | +| Chat theme | Always | Navigate to `ChatWallpaperEditorSheet` | + +All items disabled when `!contact.ready || !contact.active`. + +### Chat TTL Section + +| Element | Description | +|---|---| +| Chat TTL option | `ChatTTLOption` -- auto-delete timer for messages on this device | + +Footer: "Delete chat messages from your device." + +### Encryption Info Section + +Shown when `contact.activeConn` exists: + +| Element | Description | +|---|---| +| E2E encryption | "Quantum resistant" (PQ enabled) or "Standard" | + +### Contact Address Section + +Shown when `contact.contactLink` exists: + +| Element | Description | +|---|---| +| QR code | `SimpleXLinkQRCode` displaying the contact's address | +| Share address | Share button for the contact's SimpleX address link | + +Footer: "You can share this address with your contacts to let them connect with **[name]**." + +### Servers Section + +Shown when `contact.ready && contact.active`: + +| Element | Description | +|---|---| +| Subscription status | `SubStatusRow` showing connection health; tappable for details | +| Change receiving address | Button to switch SMP receiving queue (disabled during switch) | +| Abort changing address | Button to cancel in-progress address switch | +| Receiving via | SMP server hostnames for receiving queues | +| Sending via | SMP server hostnames for sending queues | + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Delete all messages locally (confirmation alert) | +| Delete contact | Remove contact entirely (confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal local display name | +| Database ID | API entity ID | +| Debug delivery | Button to fetch queue info via `apiContactQueueInfo` | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading connection info | `apiContactInfo` and `apiGetContactCode` called on appear; stats and code populated asynchronously | +| Progress indicator | `ProgressView` overlay during TTL changes | +| Contact not ready | Settings section disabled with reduced opacity | +| Contact inactive | Settings section disabled | +| Errors | Alert with localized error title and message | + +## Alerts + +| Alert | Trigger | +|---|---| +| `clearChatAlert` | Tap clear chat | +| `subStatusAlert` | Tap subscription status row | +| `switchAddressAlert` | Tap change receiving address | +| `abortSwitchAddressAlert` | Tap abort address change | +| `syncConnectionForceAlert` | Force ratchet sync | +| `queueInfo` | Debug delivery results | +| `someAlert` | Various sub-component alerts | + +## Related Specs + +- `spec/api.md` -- Contact API commands (info, code verification, preferences, delete) +- [Chat](chat.md) -- Parent chat view +- [Group Info](group-info.md) -- Similar pattern for group info + +## Source Files + +- `Shared/Views/Chat/ChatInfoView.swift` -- Main contact info view with all sections +- `Shared/Views/Chat/ContactPreferencesView.swift` -- Per-contact feature preferences (timed messages, reactions, voice, calls, file transfer, full delete) +- `Shared/Views/Chat/VerifyCodeView.swift` -- Security code verification via QR scan or visual comparison diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md new file mode 100644 index 0000000000..9291b3ed2f --- /dev/null +++ b/apps/ios/product/views/group-info.md @@ -0,0 +1,147 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` + - Add members -> `AddGroupMembersView` + - Group link -> `GroupLinkView` + - Group preferences -> `GroupPreferencesView` (via `GroupPreferencesButton`) + - Welcome message -> `GroupWelcomeView` + - Member info -> `GroupMemberInfoView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + - Member support -> `MemberSupportView` + - Group reports -> `GroupReportsChatNavLink` + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners) | +| Member count | "N members" label | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Focused via `aliasTextFieldFocused`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +### Group Management Section + +| Element | Condition | Description | +|---|---|---| +| Group link | `canAddMembers` and not business chat | Navigate to `GroupLinkView` to create/manage invitation link | +| Member support | Not business chat, role >= moderator | Navigate to member support chat view | +| Group reports | `canModerate` | Navigate to group reports chat | +| User support chat | Member active, role < moderator or has support chat | Navigate to own support chat with moderators | + +### Group Profile Section + +| Element | Condition | Description | +|---|---|---| +| Edit group | Owner, not business chat | Navigate to `GroupProfileView` for editing name, image, description | +| Welcome message | Has description or is owner (not business) | Navigate to `GroupWelcomeView` for add/edit | +| Group preferences | Always | Navigate to `GroupPreferencesView` -- timed messages, reactions, voice, files, direct messages, history visibility | + +Footer: "Only group owners can change group preferences." (or "Only chat owners can change preferences." for business chats) + +### Chat Settings Section + +| Element | Description | +|---|---| +| Send receipts | Toggle delivery receipts; disabled for groups > 20 current members with explanation | +| Chat theme | Navigate to `ChatWallpaperEditorSheet` | +| Chat TTL | `ChatTTLOption` -- set auto-deletion timer for messages on device | + +Footer: "Delete chat messages from your device." + +### Member List Section + +Header shows total member count (e.g., "25 members"). + +| Element | Description | +|---|---| +| Invite members button | Shown if `canAddMembers`; disabled with tap alert if incognito | +| Search field | Filter members by name (`searchText`) | +| Member rows | Each shows: avatar, display name, role badge (owner/admin/moderator/observer), online status indicator, connection status | +| Member tap | Navigates to `GroupMemberInfoView` | +| Member swipe actions | Block/unblock member, block/unblock for all (moderators) | + +Member list is sorted by role (owners first) and filtered to exclude `memLeft` and `memRemoved` statuses. + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages locally (with confirmation alert) | +| Leave group | Leave the group (with confirmation alert) | +| Delete group | Delete entire group -- only for owners (with confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal chat local display name | +| Database ID | API entity ID | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading members | Member list populated from `chatModel.groupMembers` | +| Progress indicator | `ProgressView` overlay when `progressIndicator` is true (during TTL changes) | +| Large group receipts | Receipts option disabled with "Disabled for large groups" label and info alert | +| Incognito invite blocked | Alert: "Can't invite contacts when incognito" | +| Errors | Alert with localized title and error description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteGroupAlert` | Tap delete group | +| `clearChatAlert` | Tap clear chat | +| `leaveGroupAlert` | Tap leave group | +| `cantInviteIncognitoAlert` | Tap invite members while incognito | +| `largeGroupReceiptsDisabled` | Tap receipts info on large group | +| `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions | +| `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members | + +## Related Specs + +- `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links) +- [Chat](chat.md) -- Parent chat view +- [Contact Info](contact-info.md) -- Similar pattern for direct contact info + +## Source Files + +- `Shared/Views/Chat/Group/GroupChatInfoView.swift` -- Main group info view with all sections +- `Shared/Views/Chat/Group/GroupProfileView.swift` -- Edit group name, image, description +- `Shared/Views/Chat/Group/AddGroupMembersView.swift` -- Member invitation view +- `Shared/Views/Chat/Group/GroupLinkView.swift` -- Group link creation and management +- `Shared/Views/Chat/Group/GroupPreferencesView.swift` -- Group feature preferences +- `Shared/Views/Chat/Group/GroupWelcomeView.swift` -- Welcome message editor +- `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings +- `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md new file mode 100644 index 0000000000..e53659e622 --- /dev/null +++ b/apps/ios/product/views/new-chat.md @@ -0,0 +1,94 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary onramp for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar +- **Presented by**: `NewChatSheet` modal from `ChatListView` +- **Internal navigation**: `NewChatMenuButton` provides a dropdown with options: + - "New chat" -- opens `NewChatView` + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: Segmented picker toggles between `.invite` (1-time link) and `.connect` (connect via link) +- **Swipe gesture**: Left/right swipe switches between invite and connect tabs +- **Dismiss behavior**: On dismiss, `showKeepInvitationAlert()` asks whether to keep an unused invitation link or delete it + +## Page Sections + +### Segmented Picker + +| Tab | Icon | Description | +|---|---|---| +| 1-time link | `link` | Generate and share a one-time invitation link | +| Connect via link | `qrcode` | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) + +Displayed when `selection == .invite`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share sheet for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `creatingLinkProgressView` spinner while `creatingConnReq` is true | +| Retry button | Shown if link creation fails | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. + +### Connect Tab (Connect via Link) + +Displayed when `selection == .connect`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based `CodeScanner` view for scanning SimpleX QR codes | +| Paste link field | Text input for pasting a SimpleX link manually | +| Connect button | Initiates connection via the pasted/scanned link | + +Handled by `ConnectView` sub-view with `showQRCodeScanner` state. + +### Info Sheet + +Toolbar trailing button opens `AddContactLearnMore` info sheet explaining how SimpleX connections work. + +### Add Group + +Accessed via `NewChatMenuButton` dropdown: + +| Element | Description | +|---|---| +| Group name | Required text field | +| Group image | Optional profile image picker | +| Incognito option | Create group with random profile | +| Create button | Creates group via API and navigates to group chat | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Creating invitation | `ProgressView` spinner shown; buttons disabled | +| Link creation failure | Retry button displayed | +| Invalid link pasted | Alert shown via `NewChatViewAlert.newChatSomeAlert` | +| Connection in progress | Chat list shows pending connection entry | +| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options | + +## Related Specs + +- `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress` +- [Chat List](chat-list.md) -- Parent view that presents this sheet +- [Chat](chat.md) -- Navigated to after successful connection + +## Source Files + +- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation +- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group) +- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display +- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form +- `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process diff --git a/apps/ios/product/views/onboarding.md b/apps/ios/product/views/onboarding.md new file mode 100644 index 0000000000..a283c25a19 --- /dev/null +++ b/apps/ios/product/views/onboarding.md @@ -0,0 +1,147 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, server operator conditions acceptance, and notification configuration. Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStageDefault` is not `.onboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression; back navigation hidden on later steps (`.navigationBarBackButtonHidden(true)`) +- **Completion**: Sets `onboardingStageDefault` to `.onboardingComplete` and updates `chatModel.onboardingStage` + +## Onboarding Steps + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | SimpleX Chat logo (light/dark variant based on color scheme) | +| "The future of messaging" | Info button opening `HowItWorks` sheet | +| Privacy redefined | "No user identifiers." with privacy icon | +| Immune to spam | "You decide who can connect." with shield icon | +| Decentralized | "Anybody can host servers." with decentralized icon | +| **Create your profile** button | Primary action; navigates to `CreateFirstProfile` | +| **Migrate from another device** button | Secondary action; opens `MigrateToDevice` sheet | + +The "How it works" sheet (`HowItWorks`) explains SimpleX's privacy model with an option to proceed to profile creation. + +### Step 2: Create Profile (`CreateFirstProfile`) + +**Stage**: `step2_CreateProfile` (deprecated -- now part of step 1 flow) + +| Element | Description | +|---|---| +| Display name field | Required; auto-focused after 1 second delay | +| Validation | `mkValidName` check; alerts for invalid/duplicate names | +| Create button | Calls profile creation API; advances to next step | + +Profile is stored locally and only shared with contacts. Footer explains this privacy property. + +### Step 3: Server Operator Conditions (`OnboardingConditionsView`) + +**Stage**: `step3_ChooseServerOperators` (changed to simplified conditions view) + +| Element | Description | +|---|---| +| "Conditions of use" title | Large title header | +| Privacy explanation | "Private chats, groups and your contacts are not accessible to server operators." | +| Operator selection | Toggle operators (with `selectedOperatorIds`) | +| Show conditions | Sheet to view full conditions (`ConditionsWebView`) | +| Configure operators | Sheet to customize operator settings | +| **Accept** button | Accepts conditions and advances to notifications step | + +Previous deprecated step `step3_CreateSimpleXAddress` (`CreateSimpleXAddress`) is no longer in the active flow. + +### Step 4: Set Notification Mode (`SetNotificationsMode`) + +**Stage**: `step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| "Push notifications" title | Large title header | +| Info text | Explanation of notification modes | +| Mode selector | `NtfModeSelector` for each `NotificationsMode.values` | +| **Enable notifications** / **Use chat** button | Sets notification mode and completes onboarding | +| Info sheet | `NotificationsInfoView` accessible for detailed explanation | + +Notification modes: + +| Mode | Description | +|---|---| +| Instant | Background connection maintained; real-time notifications | +| Periodic | Checks every 10 minutes; battery-friendly | +| Off | No push notifications; messages received only when app is open | + +On completion, `onboardingStageDefault.set(.onboardingComplete)` is called. + +### Completion + +**Stage**: `onboardingComplete` + +`OnboardingView` renders `EmptyView()` and the app proceeds to `ChatListView`. + +## Optional Paths + +### Migrate from Another Device + +- Triggered from Step 1 via "Migrate from another device" button +- Sets `chatModel.migrationState = .pasteOrScanLink` +- Opens `MigrateToDevice` in a sheet within `NavigationView` +- User pastes or scans a migration link from the source device +- Imports database and settings from the linked device + +### What's New (`WhatsNewView`) + +- Not part of the linear onboarding flow +- Shown when `DEFAULT_WHATS_NEW_VERSION` differs from current version +- Accessible later from Settings > Help > What's new +- Displays changelog with feature descriptions + +## Onboarding Stage Enum + +``` +enum OnboardingStage: String { + case step1_SimpleXInfo + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // conditions acceptance + case step4_SetNotificationsMode + case onboardingComplete +} +``` + +Persisted via `DEFAULT_ONBOARDING_STAGE` in `UserDefaults`. + +## Loading / Error States + +| State | Behavior | +|---|---| +| No device token | Alert "No device token!" if trying to set notification mode without token | +| Profile creation error | Alert with error description | +| Migration failure | Error handling within `MigrateToDevice` flow | +| Conditions loading | Async fetch of operator conditions | + +## Related Specs + +- `spec/architecture.md` -- App architecture and initialization flow +- [Chat List](chat-list.md) -- Destination after onboarding completes +- [User Profiles](user-profiles.md) -- Profile created during onboarding; additional profiles later +- [Settings](settings.md) -- Notification and server settings revisitable after onboarding + +## Source Files + +- `Shared/Views/Onboarding/OnboardingView.swift` -- Step router and `OnboardingStage` enum definition +- `Shared/Views/Onboarding/SimpleXInfo.swift` -- Step 1: Welcome screen with privacy highlights and migration entry +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared between onboarding and user profiles) +- `Shared/Views/Onboarding/CreateSimpleXAddress.swift` -- Deprecated step 3: SimpleX address creation +- `Shared/Views/Onboarding/ChooseServerOperators.swift` -- Step 3: Server operator conditions and selection +- `Shared/Views/Onboarding/SetNotificationsMode.swift` -- Step 4: Push notification mode selection +- `Shared/Views/Onboarding/HowItWorks.swift` -- "How it works" info sheet from step 1 +- `Shared/Views/Onboarding/WhatsNewView.swift` -- Changelog / what's new display +- `Shared/Views/Onboarding/AddressCreationCard.swift` -- Address creation prompt card diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md new file mode 100644 index 0000000000..58507ce52b --- /dev/null +++ b/apps/ios/product/views/settings.md @@ -0,0 +1,172 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option +- **Presented by**: `UserPickerSheetView(sheet: .settings)` wrapping `SettingsView` in a `NavigationView` +- **Navigation title**: "Your settings" +- **Sub-navigation**: Each settings row is a `NavigationLink` to a dedicated settings view + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | +| Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | +| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | + +All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. + +#### Notifications (`NotificationsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background connection) / Periodic (every 10 min) / Off | +| Notification preview | Hidden / Contact name only / Message preview | +| Token status indicator | Icon color reflects: new, registered, confirmed (yellow), active (green), expired, invalid | + +#### Network & Servers (`NetworkAndServers`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | +| Show sent via proxy | Toggle to show proxy indicator on sent messages | +| Show subscription % | Toggle to show server subscription percentage | + +Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift` + +#### Privacy & Security (`PrivacySettings`) + +| Setting | Description | +|---|---| +| SimpleX Lock | Enable biometric (Face ID / Touch ID) or passcode lock | +| Lock mode | System biometric or custom passcode | +| Lock timeout | Delay before lock activates (0s to 30min) | +| Self-destruct | Optional self-destruct passcode that wipes all data | +| Screen protection | Hide app content in app switcher | +| Encrypt local files | Encrypt media and files stored on device | +| Auto-accept images | Automatically download received images | +| Link previews | Generate link previews for sent URLs | +| SimpleX link mode | Description / Full link / Via browser | +| Chat previews | Show message previews in chat list | +| Save last draft | Remember unsent message drafts | +| Delivery receipts | Enable/disable read receipts globally | +| Media blur radius | Blur level for received media before tapping | + +#### Appearance (`AppearanceSettings`) + +| Setting | Description | +|---|---| +| App icon | Alternative app icon selection | +| Language | Interface language | +| Theme | System / Light / Dark | +| Dark theme variant | Dark / SimpleX / Black | +| Active theme colors | Accent color, chat bubble colors, text colors | +| Wallpapers | Chat background wallpaper selection and customization | +| Profile image corner radius | Adjust avatar roundness | +| Chat bubble roundness | Adjust message bubble corner radius | +| Chat bubble tail | Toggle message bubble tail/pointer | +| Toolbar opacity | `ToolbarMaterial` transparency setting | +| One-hand UI | Bottom toolbar layout for reachability | + +#### Audio & Video Calls (`CallSettings`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / Allow direct | +| ICE servers | Custom STUN/TURN server configuration | +| CallKit integration | Enable/disable native iOS call UI | +| Calls in recents | Show/hide calls in Phone app history | +| Lock screen calls | Show/accept on lock screen options | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `internaldrive` (orange if unencrypted) | `DatabaseView` | Passphrase management, export/import database, file storage stats | +| Migrate to another device | `tray.and.arrow.up` | `MigrateFromDevice` | Export database and generate migration link | + +Database row shows exclamation octagon icon in red when `chatRunning == false`. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use it | `questionmark` | `ChatHelp` | Usage guide with user's display name | +| What's new | `plus` | `WhatsNewView` | Changelog and new features | +| About SimpleX Chat | `info` | `SimpleXInfo` | About page with privacy explanation | +| Send questions and ideas | `number` | Opens SimpleX team chat link | Direct contact with developers | +| Send us email | `envelope` | `mailto:chat@simplex.chat` | Email link | + +### Support SimpleX Chat Section + +| Row | Icon | Action | +|---|---|---| +| Contribute | `keyboard` | Opens GitHub contribution guide | +| Rate the app | `star` | `SKStoreReviewController.requestReview` | +| Star on GitHub | GitHub icon | Opens GitHub repository | + +### Develop Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| App version | (none) | `VersionView` | Shows "v{version} ({build})" | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat not running | Most navigation links disabled; database row shows warning | +| Database not encrypted | Database icon shown in orange | +| Migration in progress | `showProgress` overlays `ProgressView` on entire settings view | +| Terminal cleanup | On disappear: `chatModel.showingTerminal = false`, terminal items cleared | + +## App Defaults + +Key `UserDefaults` / `AppStorage` keys managed by settings: +- `DEFAULT_PERFORM_LA`, `DEFAULT_LA_MODE`, `DEFAULT_LA_LOCK_DELAY`, `DEFAULT_LA_SELF_DESTRUCT` +- `DEFAULT_PRIVACY_ACCEPT_IMAGES`, `DEFAULT_PRIVACY_LINK_PREVIEWS`, `DEFAULT_PRIVACY_PROTECT_SCREEN` +- `DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS`, `DEFAULT_PRIVACY_SAVE_LAST_DRAFT` +- `DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET`, `DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS` +- `DEFAULT_WEBRTC_POLICY_RELAY`, `DEFAULT_WEBRTC_ICE_SERVERS`, `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` +- `DEFAULT_CURRENT_THEME`, `DEFAULT_SYSTEM_DARK_THEME`, `DEFAULT_THEME_OVERRIDES` +- `DEFAULT_PROFILE_IMAGE_CORNER_RADIUS`, `DEFAULT_CHAT_ITEM_ROUNDNESS`, `DEFAULT_CHAT_ITEM_TAIL` +- `DEFAULT_TOOLBAR_MATERIAL`, `DEFAULT_ONE_HAND_UI_CARD_SHOWN` +- `DEFAULT_DEVELOPER_TOOLS`, `DEFAULT_SHOW_SENT_VIA_RPOXY`, `DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE` + +## Related Specs + +- `spec/architecture.md` -- App architecture overview +- `spec/services/theme.md` -- Theme system specification +- [Chat List](chat-list.md) -- Parent view via UserPicker +- [User Profiles](user-profiles.md) -- Profile management (separate UserPicker option) + +## Source Files + +- `Shared/Views/UserSettings/SettingsView.swift` -- Main settings view, section layout, app defaults definitions +- `Shared/Views/UserSettings/NotificationsView.swift` -- Notification mode and preview settings +- `Shared/Views/UserSettings/AppearanceSettings.swift` -- Theme, wallpaper, UI customization +- `Shared/Views/UserSettings/PrivacySettings.swift` -- Privacy and security settings +- `Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift` -- Server and network configuration +- `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` -- TCP/timeout settings +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` -- SMP/XFTP server list +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` -- Individual server edit +- `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server +- `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code +- `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration +- `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display diff --git a/apps/ios/product/views/user-profiles.md b/apps/ios/product/views/user-profiles.md new file mode 100644 index 0000000000..5a38db1816 --- /dev/null +++ b/apps/ios/product/views/user-profiles.md @@ -0,0 +1,137 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/state.md](../../spec/state.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password and support a self-destruct password option. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserPickerSheetView(sheet: .chatProfiles)` wrapping `UserProfilesView` in a `NavigationView` +- **Navigation title**: "Your chat profiles" +- **Sub-navigation**: + - Create profile -> `CreateProfile` + - Edit profile -> profile detail view (via `selectedUser`) + - User address -> `UserAddressView` (via UserPicker `.address` sheet) + +## Page Sections + +### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against profile names and hidden profile passwords + +### Profile List + +Each row rendered by `userView()`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark or highlighted state for the current active profile | +| Profile image | Avatar circle with profile image or colored initials | +| Display name | Profile's display name | +| Unread count | Badge showing unread message count across all chats for this profile | +| Muted indicator | Bell-slash icon if profile notifications are muted | +| Hidden indicator | Lock icon for hidden profiles (only shown when revealed via password) | + +### Profile Actions + +Available via tap on a profile row: + +| Action | Condition | Description | +|---|---|---| +| Switch active | Different from current | Activates the selected profile; all chats switch context | +| Mute / Unmute | Any profile | Toggle notification muting for the profile; shows alert on first mute (`showMuteProfileAlert`) | +| Hide / Unhide | Non-active profile | Hide with password or reveal a hidden profile | +| Delete | Non-active profile | Delete with confirmation; option to delete data from servers | + +### Add Profile Button + +| Element | Description | +|---|---| +| "Add profile" label | `Label("Add profile", systemImage: "plus")` | +| Navigation | `NavigationLink` to `CreateProfile` view | +| Auth required | Requires local authentication before creating | + +Only shown when `trimmedSearchTextOrPassword` is empty (not searching/entering password). + +### Hidden Profile Banner + +Shown when `profileHidden` is true (a profile was just hidden): + +| Element | Description | +|---|---| +| Lock icon | `lock.open` system image | +| Message | "Enter password above to show!" | +| Tap action | Dismisses the banner with animation | + +### Create Profile (`CreateProfile`) + +| Field | Description | +|---|---| +| Display name | Required text field with validation (`mkValidName`) | +| Bio | Optional bio text (max 160 bytes) | +| Create button | Disabled until valid name entered and bio within limit | + +Validation alerts: `duplicateUserError`, `invalidDisplayNameError`, `createUserError`, `invalidNameError`. + +## Profile Visibility + +| Visibility | Description | +|---|---| +| Public | Normal profile, always visible in the list | +| Hidden | Protected by password; not shown unless password entered in search field | +| Muted | Notifications suppressed; visual indicator in profile list | + +### Hidden Profile Password Management + +- Set password when hiding a profile +- Password verified when entering in the search/password field +- `UserProfileAction.unhideUser` requires password entry +- Self-destruct password: Optional secondary password (`DEFAULT_LA_SELF_DESTRUCT`) that wipes all app data when entered + +### Delete Profile + +Two-stage confirmation: + +1. `confirmDeleteUser()` shows initial confirmation +2. `UserProfilesAlert.deleteUser(user:, delSMPQueues:)` with option to delete queues from servers +3. Requires local authentication (`withAuth`) before proceeding + +## Loading / Error States + +| State | Behavior | +|---|---| +| Authentication required | `authorized` state; prompts biometric/passcode before profile operations | +| Profile switch | Async operation; profile switch errors shown via `activateUserError` alert | +| Delete in progress | Profile removed from list; server queue deletion is async | +| Errors | Alert with localized error title and description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteUser` | Confirm profile deletion | +| `hiddenProfilesNotice` | First-time hidden profiles explanation (`showHiddenProfilesNotice`) | +| `muteProfileAlert` | First-time mute explanation (`showMuteProfileAlert`) | +| `activateUserError` | Profile switch failure | +| `error` | General error display | + +## Related Specs + +- `spec/api.md` -- User management API commands (create user, delete user, activate user, hide user) +- `spec/state.md` -- Application state: `chatModel.users`, `chatModel.currentUser` +- [Chat List](chat-list.md) -- Reflects active profile's chats +- [Settings](settings.md) -- Accessed from same UserPicker menu +- [Onboarding](onboarding.md) -- Initial profile creation during first launch + +## Source Files + +- `Shared/Views/UserSettings/UserProfilesView.swift` -- Main profiles list, search/password, profile actions, delete confirmation +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared with onboarding and profiles view) +- `Shared/Views/UserSettings/UserAddressView.swift` -- User's SimpleX address management (create, share, delete) +- `Shared/Views/ChatList/UserPicker.swift` -- Profile switcher sheet that navigates to this view diff --git a/apps/ios/spec/README.md b/apps/ios/spec/README.md new file mode 100644 index 0000000000..eca6103582 --- /dev/null +++ b/apps/ios/spec/README.md @@ -0,0 +1,74 @@ +# SimpleX Chat iOS -- Specification Overview + +> Technical specification suite for the SimpleX Chat iOS application. Each document provides bidirectional links to product documentation and source code. + +## Executive Summary + +The SimpleX Chat iOS app is a native SwiftUI frontend that communicates with a Haskell core library via C FFI. All chat logic, encryption, protocol handling, and database operations happen in the Haskell core (`chat_ctrl`). The iOS layer handles UI rendering, system integration (CallKit, Push Notifications, Background Tasks), local preferences, and theming. The app shares its database with a Notification Service Extension (NSE) for decrypting push payloads while the main app is inactive. + +## Dependency Graph + +``` +SimpleXApp (root entry point) +├── ChatModel (ObservableObject state) <-> SimpleXAPI (FFI bridge) <-> Haskell Core (chat_ctrl) +├── Views (SwiftUI) +│ ├── ChatListView -> ChatView -> ComposeView +│ ├── ChatItemView (renders individual messages) +│ ├── Settings, UserProfiles, Onboarding +│ └── ActiveCallView (WebRTC + CallKit) +├── Models +│ ├── ChatModel (global app state -- singleton) +│ ├── ItemsModel (per-chat message list state -- singleton + secondary instances) +│ ├── ChatTagsModel (tag filtering state) +│ └── Chat (per-conversation observable state) +├── Services +│ ├── NtfManager (push notification coordination) +│ ├── BGManager (background task scheduling) +│ ├── CallController (CallKit + VoIP push) +│ └── ThemeManager (theme resolution engine) +└── Extensions + ├── SimpleX NSE (Notification Service Extension -- decrypts push payloads) + └── SimpleX SE (Share Extension) +``` + +## Specification Documents + +| Document | Description | +|----------|-------------| +| [Architecture](architecture.md) | System architecture, FFI bridge, app lifecycle, extension model | +| [Chat API Reference](api.md) | Complete ChatCommand, ChatResponse, ChatEvent, ChatError type reference | +| [State Management](state.md) | ChatModel, ItemsModel, Chat, ChatInfo, preference storage | +| [Database & Storage](database.md) | SQLite databases, encryption, file storage, export/import | +| [Chat View](client/chat-view.md) | Message rendering, chat item types, context menu actions | +| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions | +| [Message Composition](client/compose.md) | Compose bar, attachments, reply/edit/forward modes, voice recording | +| [Navigation](client/navigation.md) | Navigation stack, deep linking, sheet presentation, call overlay | +| [Push Notifications](services/notifications.md) | NtfManager, NSE, notification modes, token lifecycle | +| [WebRTC Calling](services/calls.md) | CallController, WebRTCClient, CallKit, signaling via SMP | +| [File Transfer](services/files.md) | Inline/XFTP transfer, auto-receive, CryptoFile, file constants | +| [Theme Engine](services/theme.md) | ThemeManager, default themes, customization layers, wallpapers | +| [Impact Graph](impact.md) | Source file → product concept mapping, risk levels | + +## Related Product Documentation + +- [Product Overview](../product/README.md) +- [Concept Index](../product/concepts.md) +- [Business Rules](../product/rules.md) +- [Known Gaps](../product/gaps.md) +- [Glossary](../product/glossary.md) +- [Chat List View](../product/views/chat-list.md) +- [Chat View](../product/views/chat.md) + +## Source Code Entry Points + +| File | Role | +|------|------| +| `Shared/SimpleXApp.swift` | App entry point, Haskell init, lifecycle management | +| `Shared/AppDelegate.swift` | UIApplicationDelegate for push token registration | +| `Shared/ContentView.swift` | Root view -- authentication gate, call overlay, navigation | +| `Shared/Model/ChatModel.swift` | Primary observable state (ChatModel, ItemsModel, Chat) | +| `Shared/Model/SimpleXAPI.swift` | FFI bridge -- chatSendCmd, chatApiSendCmd, sendSimpleXCmd | +| `Shared/Model/AppAPITypes.swift` | ChatCommand, ChatResponse, ChatEvent enums (iOS app layer) | +| `SimpleXChat/APITypes.swift` | APIResult, ChatError, ChatCmdProtocol (shared framework) | +| `SimpleXChat/ChatTypes.swift` | User, ChatInfo, Contact, GroupInfo, ChatItem data types | +| `SimpleXChat/SimpleX.h` | C header for Haskell FFI functions | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md new file mode 100644 index 0000000000..fe40a4c4ec --- /dev/null +++ b/apps/ios/spec/api.md @@ -0,0 +1,600 @@ +# SimpleX Chat iOS -- Chat API Reference + +> Complete specification of the ChatCommand, ChatResponse, ChatEvent, and ChatError types that form the API between the Swift UI layer and the Haskell core. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +**Source:** [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | [`APITypes.swift`](../SimpleXChat/APITypes.swift) | [`API.swift`](../SimpleXChat/API.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories (ChatCommand)](#2-command-categories) +3. [Response Types (ChatResponse)](#3-response-types) +4. [Event Types (ChatEvent)](#4-event-types) +5. [Error Types (ChatError)](#5-error-types) +6. [FFI Bridge Functions](#6-ffi-bridge-functions) +7. [Result Type (APIResult)](#7-result-type) + +--- + +## 1. Overview + +The iOS app communicates with the Haskell core exclusively through a command/response protocol: + +1. Swift constructs a `ChatCommand` enum value +2. The command's `cmdString` property serializes it to a text command +3. The FFI bridge sends the string to Haskell via `chat_send_cmd_retry` +4. Haskell returns a JSON response, decoded as `APIResult` +5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent` + +**Source files**: +- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L14](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L647](../Shared/Model/AppAPITypes.swift#L649)), `ChatResponse1` ([L768](../Shared/Model/AppAPITypes.swift#L771)), `ChatResponse2` ([L907](../Shared/Model/AppAPITypes.swift#L911)), `ChatEvent` ([L1050](../Shared/Model/AppAPITypes.swift#L1055)) enums +- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L26](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L63](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L695](../SimpleXChat/APITypes.swift#L699)) +- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L117](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L230](../Shared/Model/SimpleXAPI.swift#L237)) +- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L114](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L136](../SimpleXChat/API.swift#L137)) +- `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.) +- `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`) + +--- + +## 2. Command Categories + +The `ChatCommand` enum ([`AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. + +### 2.1 User Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showActiveUser` | -- | Get current active user | [L15](../Shared/Model/AppAPITypes.swift#L16) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L16](../Shared/Model/AppAPITypes.swift#L17) | +| `listUsers` | -- | List all user profiles | [L17](../Shared/Model/AppAPITypes.swift#L18) | +| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L18](../Shared/Model/AppAPITypes.swift#L19) | +| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L23](../Shared/Model/AppAPITypes.swift#L24) | +| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L24](../Shared/Model/AppAPITypes.swift#L25) | +| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L25](../Shared/Model/AppAPITypes.swift#L26) | +| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L27) | +| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L27](../Shared/Model/AppAPITypes.swift#L28) | +| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L138](../Shared/Model/AppAPITypes.swift#L139) | +| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L19](../Shared/Model/AppAPITypes.swift#L20) | +| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L20](../Shared/Model/AppAPITypes.swift#L21) | +| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L22) | +| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L22](../Shared/Model/AppAPITypes.swift#L23) | + +### 2.2 Chat Lifecycle Control + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L28](../Shared/Model/AppAPITypes.swift#L29) | +| `checkChatRunning` | -- | Check if chat is running | [L29](../Shared/Model/AppAPITypes.swift#L30) | +| `apiStopChat` | -- | Stop chat engine | [L30](../Shared/Model/AppAPITypes.swift#L31) | +| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L31](../Shared/Model/AppAPITypes.swift#L32) | +| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L32](../Shared/Model/AppAPITypes.swift#L33) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L33](../Shared/Model/AppAPITypes.swift#L34) | +| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L34](../Shared/Model/AppAPITypes.swift#L35) | + +### 2.3 Chat & Message Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L43](../Shared/Model/AppAPITypes.swift#L44) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L44](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L45](../Shared/Model/AppAPITypes.swift#L46) | +| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L46](../Shared/Model/AppAPITypes.swift#L47) | +| `apiSendMessages` | `type, id, scope, live, ttl, composedMessages` | Send one or more messages | [L47](../Shared/Model/AppAPITypes.swift#L48) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L53](../Shared/Model/AppAPITypes.swift#L54) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L55](../Shared/Model/AppAPITypes.swift#L56) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L56](../Shared/Model/AppAPITypes.swift#L57) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L57](../Shared/Model/AppAPITypes.swift#L58) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L60](../Shared/Model/AppAPITypes.swift#L61) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L61](../Shared/Model/AppAPITypes.swift#L62) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L62](../Shared/Model/AppAPITypes.swift#L63) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, from..., itemIds, ttl` | Forward messages | [L63](../Shared/Model/AppAPITypes.swift#L64) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L54](../Shared/Model/AppAPITypes.swift#L55) | +| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L163](../Shared/Model/AppAPITypes.swift#L164) | +| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L164](../Shared/Model/AppAPITypes.swift#L165) | +| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L165](../Shared/Model/AppAPITypes.swift#L166) | + +### 2.4 Contact Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiAddContact` | `userId, incognito` | Create invitation link | [L123](../Shared/Model/AppAPITypes.swift#L124) | +| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L133](../Shared/Model/AppAPITypes.swift#L134) | +| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L126](../Shared/Model/AppAPITypes.swift#L127) | +| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L127](../Shared/Model/AppAPITypes.swift#L128) | +| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L131](../Shared/Model/AppAPITypes.swift#L132) | +| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L134](../Shared/Model/AppAPITypes.swift#L135) | +| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L151](../Shared/Model/AppAPITypes.swift#L152) | +| `apiRejectContact` | `contactReqId` | Reject contact request | [L152](../Shared/Model/AppAPITypes.swift#L153) | +| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L135](../Shared/Model/AppAPITypes.swift#L136) | +| `apiClearChat` | `type, id` | Clear conversation history | [L136](../Shared/Model/AppAPITypes.swift#L137) | +| `apiListContacts` | `userId` | List all contacts | [L137](../Shared/Model/AppAPITypes.swift#L138) | +| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L139](../Shared/Model/AppAPITypes.swift#L140) | +| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L140](../Shared/Model/AppAPITypes.swift#L141) | +| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L142](../Shared/Model/AppAPITypes.swift#L143) | +| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L109](../Shared/Model/AppAPITypes.swift#L110) | +| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L124](../Shared/Model/AppAPITypes.swift#L125) | + +### 2.5 Group Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L71](../Shared/Model/AppAPITypes.swift#L72) | +| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L72](../Shared/Model/AppAPITypes.swift#L73) | +| `apiJoinGroup` | `groupId` | Accept group invitation | [L73](../Shared/Model/AppAPITypes.swift#L74) | +| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L74](../Shared/Model/AppAPITypes.swift#L75) | +| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L78](../Shared/Model/AppAPITypes.swift#L79) | +| `apiLeaveGroup` | `groupId` | Leave group | [L79](../Shared/Model/AppAPITypes.swift#L80) | +| `apiListMembers` | `groupId` | List group members | [L80](../Shared/Model/AppAPITypes.swift#L81) | +| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L81](../Shared/Model/AppAPITypes.swift#L82) | +| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L76](../Shared/Model/AppAPITypes.swift#L77) | +| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L77](../Shared/Model/AppAPITypes.swift#L78) | +| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L82](../Shared/Model/AppAPITypes.swift#L83) | +| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L83](../Shared/Model/AppAPITypes.swift#L84) | +| `apiDeleteGroupLink` | `groupId` | Delete group link | [L84](../Shared/Model/AppAPITypes.swift#L85) | +| `apiGetGroupLink` | `groupId` | Get existing group link | [L85](../Shared/Model/AppAPITypes.swift#L86) | +| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L86](../Shared/Model/AppAPITypes.swift#L87) | +| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L87](../Shared/Model/AppAPITypes.swift#L88) | +| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L88](../Shared/Model/AppAPITypes.swift#L89) | +| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L110](../Shared/Model/AppAPITypes.swift#L111) | +| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L75](../Shared/Model/AppAPITypes.swift#L76) | +| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L108](../Shared/Model/AppAPITypes.swift#L109) | +| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L141](../Shared/Model/AppAPITypes.swift#L142) | + +### 2.6 Chat Tags + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChatTags` | `userId` | Get all user tags | [L42](../Shared/Model/AppAPITypes.swift#L43) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L48](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L49](../Shared/Model/AppAPITypes.swift#L50) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L50](../Shared/Model/AppAPITypes.swift#L51) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L51](../Shared/Model/AppAPITypes.swift#L52) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L52](../Shared/Model/AppAPITypes.swift#L53) | + +### 2.7 File Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L166](../Shared/Model/AppAPITypes.swift#L167) | +| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L167](../Shared/Model/AppAPITypes.swift#L168) | +| `cancelFile` | `fileId` | Cancel file transfer | [L168](../Shared/Model/AppAPITypes.swift#L169) | +| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L178](../Shared/Model/AppAPITypes.swift#L179) | +| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L179](../Shared/Model/AppAPITypes.swift#L180) | +| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L180](../Shared/Model/AppAPITypes.swift#L181) | + +### 2.8 WebRTC Call Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L154](../Shared/Model/AppAPITypes.swift#L155) | +| `apiRejectCall` | `contact` | Reject incoming call | [L155](../Shared/Model/AppAPITypes.swift#L156) | +| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L156](../Shared/Model/AppAPITypes.swift#L157) | +| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L157](../Shared/Model/AppAPITypes.swift#L158) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L158](../Shared/Model/AppAPITypes.swift#L159) | +| `apiEndCall` | `contact` | End active call | [L159](../Shared/Model/AppAPITypes.swift#L160) | +| `apiGetCallInvitations` | -- | Get pending call invitations | [L160](../Shared/Model/AppAPITypes.swift#L161) | +| `apiCallStatus` | `contact, callStatus` | Report call status change | [L161](../Shared/Model/AppAPITypes.swift#L162) | + +### 2.9 Push Notifications + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetNtfToken` | -- | Get current notification token | [L64](../Shared/Model/AppAPITypes.swift#L65) | +| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L65](../Shared/Model/AppAPITypes.swift#L66) | +| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L66](../Shared/Model/AppAPITypes.swift#L67) | +| `apiCheckToken` | `token` | Check token status | [L67](../Shared/Model/AppAPITypes.swift#L68) | +| `apiDeleteToken` | `token` | Unregister token | [L68](../Shared/Model/AppAPITypes.swift#L69) | +| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L69](../Shared/Model/AppAPITypes.swift#L70) | +| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L71) | + +### 2.10 Settings & Configuration + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L40](../Shared/Model/AppAPITypes.swift#L41) | +| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L41](../Shared/Model/AppAPITypes.swift#L42) | +| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L107](../Shared/Model/AppAPITypes.swift#L108) | +| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L99](../Shared/Model/AppAPITypes.swift#L100) | +| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L100](../Shared/Model/AppAPITypes.swift#L101) | +| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L101](../Shared/Model/AppAPITypes.swift#L102) | +| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L102](../Shared/Model/AppAPITypes.swift#L103) | +| `apiGetNetworkConfig` | -- | Get network configuration | [L103](../Shared/Model/AppAPITypes.swift#L104) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L104](../Shared/Model/AppAPITypes.swift#L105) | +| `reconnectAllServers` | -- | Force reconnect all servers | [L105](../Shared/Model/AppAPITypes.swift#L106) | +| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L106](../Shared/Model/AppAPITypes.swift#L107) | + +### 2.11 Database & Storage + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L38](../Shared/Model/AppAPITypes.swift#L39) | +| `testStorageEncryption` | `key: String` | Test encryption key | [L39](../Shared/Model/AppAPITypes.swift#L40) | +| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L35](../Shared/Model/AppAPITypes.swift#L36) | +| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L36](../Shared/Model/AppAPITypes.swift#L37) | +| `apiDeleteStorage` | -- | Delete all storage | [L37](../Shared/Model/AppAPITypes.swift#L38) | + +### 2.12 Server Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetServerOperators` | -- | Get server operators | [L91](../Shared/Model/AppAPITypes.swift#L92) | +| `apiSetServerOperators` | `operators` | Set server operators | [L92](../Shared/Model/AppAPITypes.swift#L93) | +| `apiGetUserServers` | `userId` | Get user's configured servers | [L93](../Shared/Model/AppAPITypes.swift#L94) | +| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L94](../Shared/Model/AppAPITypes.swift#L95) | +| `apiValidateServers` | `userId, userServers` | Validate server configuration | [L95](../Shared/Model/AppAPITypes.swift#L96) | +| `apiGetUsageConditions` | -- | Get usage conditions | [L96](../Shared/Model/AppAPITypes.swift#L97) | +| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L98](../Shared/Model/AppAPITypes.swift#L99) | +| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L90](../Shared/Model/AppAPITypes.swift#L91) | + +### 2.13 Theme & UI + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L143](../Shared/Model/AppAPITypes.swift#L144) | +| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L144](../Shared/Model/AppAPITypes.swift#L145) | + +### 2.14 Remote Desktop + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L170](../Shared/Model/AppAPITypes.swift#L171) | +| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L171](../Shared/Model/AppAPITypes.swift#L172) | +| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L172](../Shared/Model/AppAPITypes.swift#L173) | +| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L173](../Shared/Model/AppAPITypes.swift#L174) | +| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L174](../Shared/Model/AppAPITypes.swift#L175) | +| `listRemoteCtrls` | -- | List known remote controllers | [L175](../Shared/Model/AppAPITypes.swift#L176) | +| `stopRemoteCtrl` | -- | Stop remote session | [L176](../Shared/Model/AppAPITypes.swift#L177) | +| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L177](../Shared/Model/AppAPITypes.swift#L178) | + +### 2.15 Diagnostics + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showVersion` | -- | Get core version info | [L182](../Shared/Model/AppAPITypes.swift#L183) | +| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L183](../Shared/Model/AppAPITypes.swift#L184) | +| `getAgentServersSummary` | `userId` | Get server summary stats | [L184](../Shared/Model/AppAPITypes.swift#L185) | +| `resetAgentServersStats` | -- | Reset server statistics | [L185](../Shared/Model/AppAPITypes.swift#L186) | + +### 2.16 Address Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L145](../Shared/Model/AppAPITypes.swift#L146) | +| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L146](../Shared/Model/AppAPITypes.swift#L147) | +| `apiShowMyAddress` | `userId` | Show current address | [L147](../Shared/Model/AppAPITypes.swift#L148) | +| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L148](../Shared/Model/AppAPITypes.swift#L149) | +| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L149](../Shared/Model/AppAPITypes.swift#L150) | +| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L150](../Shared/Model/AppAPITypes.swift#L151) | + +### 2.17 Connection Security + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetContactCode` | `contactId` | Get verification code | [L119](../Shared/Model/AppAPITypes.swift#L120) | +| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L120](../Shared/Model/AppAPITypes.swift#L121) | +| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L121](../Shared/Model/AppAPITypes.swift#L122) | +| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L122](../Shared/Model/AppAPITypes.swift#L123) | +| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L113](../Shared/Model/AppAPITypes.swift#L114) | +| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L114](../Shared/Model/AppAPITypes.swift#L115) | +| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L115](../Shared/Model/AppAPITypes.swift#L116) | +| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L116](../Shared/Model/AppAPITypes.swift#L117) | +| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L117](../Shared/Model/AppAPITypes.swift#L118) | +| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L118](../Shared/Model/AppAPITypes.swift#L119) | + +--- + +## 3. Response Types + +Responses are split across three enums due to Swift enum size limitations: + +### ChatResponse0 + +Synchronous query responses ([`AppAPITypes.swift` L647](../Shared/Model/AppAPITypes.swift#L649)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `activeUser` | `user: User` | Current active user | [L648](../Shared/Model/AppAPITypes.swift#L650) | +| `usersList` | `users: [UserInfo]` | All user profiles | [L649](../Shared/Model/AppAPITypes.swift#L651) | +| `chatStarted` | -- | Chat engine started | [L650](../Shared/Model/AppAPITypes.swift#L652) | +| `chatRunning` | -- | Chat is already running | [L651](../Shared/Model/AppAPITypes.swift#L653) | +| `chatStopped` | -- | Chat engine stopped | [L652](../Shared/Model/AppAPITypes.swift#L654) | +| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L653](../Shared/Model/AppAPITypes.swift#L655) | +| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L654](../Shared/Model/AppAPITypes.swift#L656) | +| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L656](../Shared/Model/AppAPITypes.swift#L658) | +| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L657](../Shared/Model/AppAPITypes.swift#L659) | +| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L658](../Shared/Model/AppAPITypes.swift#L660) | +| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L664](../Shared/Model/AppAPITypes.swift#L666) | +| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L665](../Shared/Model/AppAPITypes.swift#L667) | +| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L666](../Shared/Model/AppAPITypes.swift#L668) | +| `connectionVerified` | `verified, expectedCode` | Verification result | [L676](../Shared/Model/AppAPITypes.swift#L678) | +| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L677](../Shared/Model/AppAPITypes.swift#L679) | + +### ChatResponse1 + +Contact, message, and profile responses ([`AppAPITypes.swift` L768](../Shared/Model/AppAPITypes.swift#L771)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L769](../Shared/Model/AppAPITypes.swift#L772) | +| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L772](../Shared/Model/AppAPITypes.swift#L775) | +| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L773](../Shared/Model/AppAPITypes.swift#L776) | +| `contactDeleted` | `user, contact` | Contact deleted | [L782](../Shared/Model/AppAPITypes.swift#L785) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L800](../Shared/Model/AppAPITypes.swift#L803) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L803](../Shared/Model/AppAPITypes.swift#L806) | +| `chatItemReaction` | `user, added, reaction` | Reaction change | [L805](../Shared/Model/AppAPITypes.swift#L808) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L807](../Shared/Model/AppAPITypes.swift#L810) | +| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L808](../Shared/Model/AppAPITypes.swift#L811) | +| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L788](../Shared/Model/AppAPITypes.swift#L791) | +| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L796](../Shared/Model/AppAPITypes.swift#L799) | +| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L802](../Shared/Model/AppAPITypes.swift#L805) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L801](../Shared/Model/AppAPITypes.swift#L804) | + +### ChatResponse2 + +Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L907](../Shared/Model/AppAPITypes.swift#L911)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `groupCreated` | `user, groupInfo` | New group created | [L909](../Shared/Model/AppAPITypes.swift#L913) | +| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L910](../Shared/Model/AppAPITypes.swift#L914) | +| `groupMembers` | `user, group: Group` | Group member list | [L914](../Shared/Model/AppAPITypes.swift#L918) | +| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L918](../Shared/Model/AppAPITypes.swift#L922) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L920](../Shared/Model/AppAPITypes.swift#L924) | +| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L921](../Shared/Model/AppAPITypes.swift#L925) | +| `rcvFileAccepted` | `user, chatItem` | File download started | [L928](../Shared/Model/AppAPITypes.swift#L932) | +| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L937](../Shared/Model/AppAPITypes.swift#L941) | +| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L940](../Shared/Model/AppAPITypes.swift#L944) | +| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L948](../Shared/Model/AppAPITypes.swift#L952) | +| `cmdOk` | `user_` | Generic success | [L949](../Shared/Model/AppAPITypes.swift#L953) | +| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L953](../Shared/Model/AppAPITypes.swift#L957) | +| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L954](../Shared/Model/AppAPITypes.swift#L958) | +| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L955](../Shared/Model/AppAPITypes.swift#L959) | + +--- + +## 4. Event Types + +The `ChatEvent` enum ([`AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. + +Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266) in `SimpleXAPI.swift`. + +### Connection Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1057](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1058](../Shared/Model/AppAPITypes.swift#L1063) | +| `contactSndReady` | `user, contact` | Ready to send to contact | [L1059](../Shared/Model/AppAPITypes.swift#L1064) | +| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1056](../Shared/Model/AppAPITypes.swift#L1061) | +| `contactUpdated` | `user, toContact` | Contact profile updated | [L1061](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1060](../Shared/Model/AppAPITypes.swift#L1065) | +| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1063](../Shared/Model/AppAPITypes.swift#L1068) | + +### Message Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1065](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1067](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1068](../Shared/Model/AppAPITypes.swift#L1073) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1069](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1066](../Shared/Model/AppAPITypes.swift#L1071) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1071](../Shared/Model/AppAPITypes.swift#L1076) | +| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1064](../Shared/Model/AppAPITypes.swift#L1069) | + +### Group Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1072](../Shared/Model/AppAPITypes.swift#L1077) | +| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1073](../Shared/Model/AppAPITypes.swift#L1078) | +| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1074](../Shared/Model/AppAPITypes.swift#L1079) | +| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1076](../Shared/Model/AppAPITypes.swift#L1081) | +| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1078](../Shared/Model/AppAPITypes.swift#L1083) | +| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1079](../Shared/Model/AppAPITypes.swift#L1084) | +| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1080](../Shared/Model/AppAPITypes.swift#L1085) | +| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1081](../Shared/Model/AppAPITypes.swift#L1086) | +| `leftMember` | `user, groupInfo, member` | Member left | [L1082](../Shared/Model/AppAPITypes.swift#L1087) | +| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1083](../Shared/Model/AppAPITypes.swift#L1088) | +| `userJoinedGroup` | `user, groupInfo` | Successfully joined | [L1084](../Shared/Model/AppAPITypes.swift#L1089) | +| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1085](../Shared/Model/AppAPITypes.swift#L1090) | +| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1086](../Shared/Model/AppAPITypes.swift#L1091) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1087](../Shared/Model/AppAPITypes.swift#L1092) | +| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1062](../Shared/Model/AppAPITypes.swift#L1067) | + +### File Transfer Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `rcvFileStart` | `user, chatItem` | Download started | [L1092](../Shared/Model/AppAPITypes.swift#L1097) | +| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1093](../Shared/Model/AppAPITypes.swift#L1098) | +| `rcvFileComplete` | `user, chatItem` | Download complete | [L1094](../Shared/Model/AppAPITypes.swift#L1099) | +| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1096](../Shared/Model/AppAPITypes.swift#L1101) | +| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1097](../Shared/Model/AppAPITypes.swift#L1102) | +| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1100](../Shared/Model/AppAPITypes.swift#L1105) | +| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1101](../Shared/Model/AppAPITypes.swift#L1106) | +| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1103](../Shared/Model/AppAPITypes.swift#L1108) | +| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1105](../Shared/Model/AppAPITypes.swift#L1110) | +| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1107](../Shared/Model/AppAPITypes.swift#L1112) | + +### Call Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1110](../Shared/Model/AppAPITypes.swift#L1115) | +| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1111](../Shared/Model/AppAPITypes.swift#L1116) | +| `callAnswer` | `user, contact, answer` | SDP answer received | [L1112](../Shared/Model/AppAPITypes.swift#L1117) | +| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1113](../Shared/Model/AppAPITypes.swift#L1118) | +| `callEnded` | `user, contact` | Call ended by remote | [L1114](../Shared/Model/AppAPITypes.swift#L1119) | + +### Connection Security Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1052](../Shared/Model/AppAPITypes.swift#L1057) | +| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1053](../Shared/Model/AppAPITypes.swift#L1058) | +| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1054](../Shared/Model/AppAPITypes.swift#L1059) | +| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1055](../Shared/Model/AppAPITypes.swift#L1060) | + +### System Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `chatSuspended` | -- | Core suspended | [L1051](../Shared/Model/AppAPITypes.swift#L1056) | + +--- + +## 5. Error Types + +Defined in [`SimpleXChat/APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699): + +```swift +public enum ChatError: Decodable, Hashable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) + case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) + case invalidJSON(json: String) + case unexpectedResult(type: String) +} +``` + +### Error Categories + +| Category | Enum | Description | Source | +|----------|------|-------------|--------| +| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied) | [`APITypes.swift` L717](../SimpleXChat/APITypes.swift#L722) | +| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L873](../SimpleXChat/APITypes.swift#L878) | +| Database store | `StoreError` | SQLite query/constraint errors | [`APITypes.swift` L796](../SimpleXChat/APITypes.swift#L801) | +| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L860](../SimpleXChat/APITypes.swift#L865) | +| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1043](../SimpleXChat/APITypes.swift#L1048) | +| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | +| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | + +--- + +## 6. FFI Bridge Functions + +Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift): + +### Synchronous (blocking current thread) + +```swift +// Throws on error, returns typed result +func chatSendCmdSync( // SimpleXAPI.swift L91 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) throws -> R + +// Returns APIResult (caller handles error) +func chatApiSendCmdSync( // SimpleXAPI.swift L96 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + retryNum: Int32 = 0, + log: Bool = true +) -> APIResult +``` + +### Asynchronous (Swift concurrency) + +```swift +// Throws on error, returns typed result +func chatSendCmd( // SimpleXAPI.swift L117 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) async throws -> R + +// Returns APIResult with optional retry on network errors +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L122 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + inProgress: BoxedValue? = nil, + retryNum: Int32 = 0 +) async -> APIResult? +``` + +### Low-Level FFI + +```swift +// Direct C FFI call -- serializes cmd.cmdString, calls chat_send_cmd_retry, decodes response +public func sendSimpleXCmd( // API.swift L115 + _ cmd: ChatCmdProtocol, + _ ctrl: chat_ctrl?, + retryNum: Int32 = 0 +) -> APIResult +``` + +### Event Receiver + +```swift +// Polls for async events from the Haskell core +func chatRecvMsg( // SimpleXAPI.swift L230 + _ ctrl: chat_ctrl? = nil +) async -> APIResult? + +// Processes a received event and updates app state +func processReceivedMsg( // SimpleXAPI.swift L2248 + _ res: ChatEvent +) async +``` + +--- + +## 7. Result Type + +Defined in [`SimpleXChat/APITypes.swift` L26](../SimpleXChat/APITypes.swift#L27): + +```swift +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) // Successful response + case error(ChatError) // Error response from core + case invalid(type: String, json: Data) // Undecodable response + + public var responseType: String { ... } + public var unexpected: ChatError { ... } +} + +public protocol ChatAPIResult: Decodable { // APITypes.swift L63 + var responseType: String { get } + var details: String { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? +} +``` + +The `decodeAPIResult` function ([`APITypes.swift` L83](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +1. Try standard `JSONDecoder.decode(APIResult.self, from: data)` +2. If that fails, try manual JSON parsing via `JSONSerialization` +3. Check for `"error"` key -- return `.error` +4. Check for `"result"` key -- try `R.fallbackResult` or return `.invalid` +5. Last resort: return `.invalid(type: "invalid", json: ...)` + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15) | +| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L647, L768, L907](../Shared/Model/AppAPITypes.swift#L649) | +| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055) | +| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L26, L695](../SimpleXChat/APITypes.swift#L27) | +| FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) | +| Data types | `SimpleXChat/ChatTypes.swift` | +| C header | `SimpleXChat/SimpleX.h` | +| Haskell controller | `../../src/Simplex/Chat/Controller.hs` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md new file mode 100644 index 0000000000..84d9d3269d --- /dev/null +++ b/apps/ios/spec/architecture.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- System Architecture + +> Technical specification for the iOS app's layered architecture, FFI bridge, event system, and extension model. +> +> Related specs: [README](README.md) | [API Reference](api.md) | [State Management](state.md) | [Database](database.md) +> Related product: [Product Overview](../product/README.md) + +**Source:** [`SimpleXApp.swift`](../Shared/SimpleXApp.swift#L1-L183) | [`AppDelegate.swift`](../Shared/AppDelegate.swift#L1-L209) | [`ContentView.swift`](../Shared/ContentView.swift#L1-L513) | [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1373) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L1-L2915) | [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L1-L2357) | [`APITypes.swift`](../SimpleXChat/APITypes.swift#L1-L1071) | [`API.swift`](../SimpleXChat/API.swift#L1-L388) + +--- + +## Table of Contents + +1. [Layered Architecture](#1-layered-architecture) +2. [FFI Bridge](#2-ffi-bridge) +3. [Event Streaming](#3-event-streaming) +4. [Database Architecture](#4-database-architecture) +5. [App Lifecycle](#5-app-lifecycle) +6. [Extension Architecture](#6-extension-architecture) +7. [Remote Desktop Control](#7-remote-desktop-control) + +--- + +## [1. Layered Architecture](../Shared/SimpleXApp.swift#L17-L184) + +The app follows a strict layered model where each layer communicates only with its immediate neighbor: + +``` +┌─────────────────────────────────────────┐ +│ SwiftUI Views │ Rendering, user interaction +│ (ChatListView, ChatView, ComposeView) │ +├─────────────────────────────────────────┤ +│ ChatModel (ObservableObject) │ App state, @Published properties +│ ItemsModel, Chat, ChatTagsModel │ Per-chat state, tag filtering +├─────────────────────────────────────────┤ +│ SimpleXAPI (FFI Bridge) │ chatSendCmd/chatApiSendCmd +│ AppAPITypes (ChatCommand/Response) │ JSON serialization/deserialization +├─────────────────────────────────────────┤ +│ C FFI Layer │ chat_send_cmd_retry, chat_recv_msg_wait +│ (SimpleX.h, libsimplex.a) │ Compiled Haskell via GHC cross-compiler +├─────────────────────────────────────────┤ +│ Haskell Core (chat_ctrl) │ Chat logic, chat protocol (x-events), +│ (Simplex.Chat.Controller) │ database operations, file management +├─────────────────────────────────────────┤ +│ simplexmq library (external) │ SMP/XFTP protocols, SMP Agent, +│ (github.com/simplex-chat/simplexmq) │ double-ratchet (PQDR), transport (TLS) +└─────────────────────────────────────────┘ +``` + +**Key invariant**: No SwiftUI view directly calls FFI functions. All communication flows through `ChatModel` or dedicated API functions in `SimpleXAPI.swift`. + +### Source Files + +| Layer | File | Role | Line | +|-------|------|------|------| +| Views | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift) | Chat list rendering | | +| Views | [`Shared/Views/Chat/ChatView.swift`](../Shared/Views/Chat/ChatView.swift) | Conversation rendering | | +| State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | +| API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | +| API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | +| Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | + +--- + +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) + +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) + +The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: + +```c +typedef void* chat_ctrl; + +// Initialize database, apply migrations, return controller +char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, + int backgroundMode, chat_ctrl *ctrl); + +// Send command string, return JSON response string +char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); + +// Block until next async event arrives (or timeout) +char *chat_recv_msg_wait(chat_ctrl ctl, int wait); + +// Close/reopen database store +char *chat_close_store(chat_ctrl ctl); +char *chat_reopen_store(chat_ctrl ctl); + +// Utility: markdown parsing, server validation, password hashing +char *chat_parse_markdown(char *str); +char *chat_parse_server(char *str); +char *chat_password_hash(char *pwd, char *salt); + +// File encryption/decryption +char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); +char *chat_read_file(char *path, char *key, char *nonce); +char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); +char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); +``` + +### [Swift Bridge Functions (SimpleXAPI.swift)](../Shared/Model/SimpleXAPI.swift#L93-L221) + +```swift +// Synchronous send -- blocks calling thread +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) throws -> R // L91 + +// Async send -- dispatches to background +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) async -> APIResult // L215 + +// Low-level FFI call -- serializes command to string, calls chat_send_cmd_retry, decodes JSON +func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl?, + retryNum: Int32 = 0) -> APIResult // SimpleXChat/API.swift L114 +``` + +### Data Flow + +1. Swift constructs a `ChatCommand` enum value (e.g., `.apiSendMessages(type:id:scope:live:ttl:composedMessages:)`) +2. [`ChatCommand.cmdString`](../Shared/Model/AppAPITypes.swift#L15) serializes it to a command string (e.g., `"/_send @1 json {...}"`) +3. [`sendSimpleXCmd`](../SimpleXChat/API.swift#L115) passes the string to `chat_send_cmd_retry` via C FFI +4. Haskell core processes the command, returns JSON response string +5. Swift decodes JSON into [`APIResult`](../SimpleXChat/APITypes.swift#L27) where `R: ChatAPIResult` +6. Result is either `.result(R)`, `.error(ChatError)`, or `.invalid(type, json)` + +### [Background Task Protection](../Shared/Model/SimpleXAPI.swift#L54-L79) + +All FFI calls are wrapped in [`beginBGTask()`](../Shared/Model/SimpleXAPI.swift#L54) / `endBackgroundTask()` to prevent iOS from killing the app mid-operation. The `maxTaskDuration` is 15 seconds. + +--- + +## [3. Event Streaming](../Shared/Model/SimpleXAPI.swift#L2220-L2916) + +The Haskell core emits async events (new messages, connection status changes, file progress, etc.) that are not direct responses to commands. These are received via polling: + +``` +Haskell Core --[chat_recv_msg_wait]--> Swift event loop --> ChatModel update --> SwiftUI re-render +``` + +The event loop is implemented in [`ChatReceiver`](../Shared/Model/SimpleXAPI.swift#L2220-L2263), and events are dispatched by [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266). + +### [Event Types (ChatEvent enum)](../Shared/Model/AppAPITypes.swift#L1055-L1129) + +Key async events delivered from core to UI: + +| Event | Description | Line | +|-------|-------------|------| +| `newChatItems` | New messages received | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | Message edited by sender | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemsDeleted` | Messages deleted | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemReaction` | Reaction added/removed | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `contactConnected` | New contact connected | [L1062](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactUpdated` | Contact profile changed | [L1066](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedGroupInvitation` | Group invitation received | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `groupMemberUpdated` | Group member info changed | [L1067](../Shared/Model/AppAPITypes.swift#L1067) | +| `callInvitation` | Incoming call | [L1115](../Shared/Model/AppAPITypes.swift#L1115) | +| `chatSuspended` | Core suspended (background) | [L1056](../Shared/Model/AppAPITypes.swift#L1056) | +| `rcvFileComplete` | File download finished | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `sndFileCompleteXFTP` | File upload finished | [L1110](../Shared/Model/AppAPITypes.swift#L1110) | + +Events are decoded as [`ChatEvent`](../Shared/Model/AppAPITypes.swift#L1055) enum in `Shared/Model/AppAPITypes.swift` and dispatched to update `ChatModel` / `ItemsModel` properties, triggering SwiftUI view re-renders via `@Published` property observation. + +--- + +## [4. Database Architecture](../SimpleXChat/FileUtils.swift#L70-L294) + +Two SQLite databases in the app group container (shared with NSE): + +| Database | File | Contents | +|----------|------|----------| +| Chat DB | `simplex_v1_chat.db` | Messages, contacts, groups, profiles, files, tags, preferences | +| Agent DB | `simplex_v1_agent.db` | SMP connections, keys, queues, server info | + +Both databases use the `DB_FILE_PREFIX = "simplex_v1"` prefix. The database path is resolved via [`getAppDatabasePath()`](../SimpleXChat/FileUtils.swift#L70) in `SimpleXChat/FileUtils.swift`, which checks `dbContainerGroupDefault` to determine whether to use the app group container or legacy documents directory. + +See [Database & Storage specification](database.md) for full details. + +--- + +## [5. App Lifecycle](../Shared/SimpleXApp.swift#L17-L184) + +### [Initialization Sequence (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L17-L38) + +```swift +// SimpleXApp.init() +1. haskell_init() // Initialize Haskell RTS (background queue, sync) +2. UserDefaults.register(defaults:) // Register app preference defaults +3. setGroupDefaults() // Sync preferences to app group container +4. setDbContainer() // Set database path L122 +5. BGManager.shared.register() // Register background task handlers +6. NtfManager.shared.registerCategories() // Register notification action categories +``` + +### State Transitions + +``` + ┌──────────┐ + │ Launched │ + └─────┬─────┘ + │ initChatAndMigrate() + v + ┌──────────┐ + │ DB Setup │ chat_migrate_init_key() + └─────┬─────┘ + │ startChat() SimpleXAPI.swift L2098 + v + ┌──────────┐ + │ Active │ apiActivateChat() SimpleXAPI.swift L358 + └─────┬─────┘ + │ scenePhase == .background + v + ┌──────────┐ + │Background │ apiSuspendChat(timeoutMicroseconds:) SimpleXAPI.swift L368 + └─────┬─────┘ + │ scenePhase == .active + v + ┌──────────┐ + │ Active │ startChatAndActivate() + └──────────┘ +``` + +### [Scene Phase Handling (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L38-L123) + +- **`.active`**: Calls `startChatAndActivate()`, processes pending notification responses, refreshes chat list and call invitations +- **`.background`**: Records authentication timestamp, calls `suspendChat()` (unless CallKit call active), schedules `BGManager` background refresh, updates badge count +- **`.inactive`**: No explicit handling (transitional state) + +### CallKit Exception + +When a CallKit call is active during backgrounding, chat suspension is deferred (`CallController.shared.shouldSuspendChat = true`) until the call ends, to maintain the WebRTC session. + +--- + +## [6. Extension Architecture](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +### [Notification Service Extension (NSE)](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +The NSE ([`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228)) is a separate process that: + +1. Receives encrypted push notification payload from APNs +2. Initializes its own Haskell core instance (`chat_ctrl`) with shared database access +3. Decrypts the push payload using stored keys +4. Generates a visible `UNMutableNotificationContent` with the decrypted message preview +5. Delivers the notification to the user + +**Database sharing**: Both main app and NSE access the same database files in the app group container (`APP_GROUP_NAME`). Coordination uses file locks to prevent concurrent write conflicts. + +**Lifecycle**: The NSE has a ~30-second execution window per notification. It must initialize Haskell RTS, open the database, decrypt, and deliver within this window. + +### Share Extension (SE) + +The Share Extension (`SimpleX SE/`) allows sharing content (text, images, files) from other apps into SimpleX conversations. + +--- + +## [7. Remote Desktop Control](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545) + +Optional desktop pairing allows controlling the mobile app from a desktop client: + +- **Pairing**: Encrypted QR code scanned by desktop client establishes a session +- **Commands**: [`connectRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1613), [`findKnownRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1620), [`confirmRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1624), [`verifyRemoteCtrlSession`](../Shared/Model/SimpleXAPI.swift#L1630), [`listRemoteCtrls`](../Shared/Model/SimpleXAPI.swift#L1636), [`stopRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1642), [`deleteRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1646) +- **State**: [`ChatModel.remoteCtrlSession`](../Shared/Model/ChatModel.swift#L395)`: RemoteCtrlSession?` tracks the active session +- **Transport**: Encrypted reverse HTTP transport between mobile and desktop +- **Source**: [`Shared/Views/RemoteAccess/ConnectDesktopView.swift`](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545), see `Remote.hs` in `../../src/Simplex/Chat/` + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| App entry point | [`Shared/SimpleXApp.swift`](../Shared/SimpleXApp.swift#L17) | L17 | +| App delegate | [`Shared/AppDelegate.swift`](../Shared/AppDelegate.swift#L15) | L15 | +| Root view | [`Shared/ContentView.swift`](../Shared/ContentView.swift#L24) | L24 | +| FFI bridge | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | L93 | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift#L115) | L115 | +| App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | +| API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | +| Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | +| Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | +| Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | + +### External: simplexmq Library + +The lower-level protocol and encryption layers are in the separate [simplexmq](https://github.com/simplex-chat/simplexmq) library: + +| Component | Spec | Implementation | +|-----------|------|----------------| +| SMP protocol | `simplexmq/protocol/simplex-messaging.md` | `simplexmq/src/Simplex/Messaging/Protocol.hs` | +| XFTP protocol | `simplexmq/protocol/xftp.md` | `simplexmq/src/Simplex/FileTransfer/Protocol.hs` | +| SMP Agent (duplex connections) | `simplexmq/protocol/agent-protocol.md` | `simplexmq/src/Simplex/Messaging/Agent.hs` | +| Double ratchet (PQDR) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` | +| Post-quantum KEM (sntrup761) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs` | +| TLS transport | — | `simplexmq/src/Simplex/Messaging/Transport.hs` | +| File encryption | — | `simplexmq/src/Simplex/Messaging/Crypto/File.hs` | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md new file mode 100644 index 0000000000..0eb3cd75f7 --- /dev/null +++ b/apps/ios/spec/client/chat-list.md @@ -0,0 +1,280 @@ +# SimpleX Chat iOS -- Chat List Module + +> Technical specification for the conversation list, filtering, search, swipe actions, and user picker. +> +> Related specs: [Chat View](chat-view.md) | [Navigation](navigation.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Chat List View](../../product/views/chat-list.md) + +**Source:** [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView](#2-chatlistview) +3. [ChatPreviewView](#3-chatpreviewview) +4. [ChatListNavLink](#4-chatlistnavlink) +5. [Filtering & Tags](#5-filtering--tags) +6. [Search](#6-search) +7. [Swipe Actions](#7-swipe-actions) +8. [UserPicker](#8-userpicker) +9. [Floating Action Button](#9-floating-action-button) + +--- + +## 1. Overview + +The chat list is the main screen of the app, displaying all conversations for the current user. It provides: + +- Conversation previews with unread badges +- Filter tabs (All, Unread, Favorites, Groups, Contacts, Business, user-defined tags) +- Search across chat names and message content +- Swipe actions for quick operations +- User profile switcher +- Floating action button for new conversations + +``` +ChatListView +├── Navigation Bar +│ ├── User avatar (tap → UserPicker) +│ └── Filter tabs (TagListView) +├── Search bar (on pull-down or tap) +├── Chat List (List/LazyVStack) +│ └── ChatListNavLink (per conversation) +│ └── ChatPreviewView +│ ├── Avatar +│ ├── Chat name + last message preview +│ ├── Timestamp +│ └── Unread badge +├── FAB (New Chat button) +└── Pending connection cards +``` + +--- + +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} + +**File**: `Shared/Views/ChatList/ChatListView.swift` + +The root list view. Key responsibilities: + +### Data Source +- Reads `ChatModel.shared.chats` (all conversations) +- Applies active filter from `ChatTagsModel.shared.activeFilter` +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Sorts by last activity (most recent first), with pinned chats at top + +### Layout +- Uses SwiftUI `List` with `ForEach` over filtered chats +- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView` +- Pull-to-refresh triggers `updateChats()` API call +- Empty state: `ChatHelp` view with getting-started guidance + +### Connection Cards +- Pending contact connections (`ChatInfo.contactConnection`) shown as cards +- Contact requests (`ChatInfo.contactRequest`) shown with accept/reject UI via `ContactRequestView` + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | + +--- + +## 3. [`ChatPreviewView`](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) {#3-chatpreviewview} + +**File**: `Shared/Views/ChatList/ChatPreviewView.swift` + +Renders a single row in the chat list. Shows: + +| Element | Source | Description | +|---------|--------|-------------| +| Avatar | `chatInfo.image` | Profile image or default icon | +| Chat name | `chatInfo.displayName` | Contact name, group name, or connection label | +| Last message | `chat.chatItems.last` | Preview text of most recent message | +| Timestamp | `chat.chatItems.last?.timestampText` | Relative time of last message | +| Unread badge | `chat.chatStats.unreadCount` | Circular badge with unread count | +| Mute icon | `chatInfo.chatSettings?.enableNtfs` | Bell-slash icon if notifications muted | +| Pin icon | -- | Pin indicator for pinned chats | +| Incognito icon | Contact.contactConnIncognito | Incognito mode indicator | +| Delivery status | Last sent item's `meta.itemStatus` | Check marks for delivery confirmation | + +### Preview Text Rendering +- Text messages: first line of message content +- Images: camera icon + caption (if any) +- Files: paperclip icon + filename +- Voice: microphone icon + duration +- Calls: phone icon + call status +- Group events: system event description +- Encrypted/deleted: placeholder text + +--- + +## 4. [`ChatListNavLink`](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) {#4-chatlistnavlink} + +**File**: `Shared/Views/ChatList/ChatListNavLink.swift` + +Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior: + +### Tap Behavior +- Direct chat: navigates to `ChatView` via `ItemsModel.loadOpenChat(chatId)` -- [`contactNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L95) L93 +- Group chat: navigates to `ChatView` -- [`groupNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L217) L214 +- Contact request: shows `ContactRequestView` with accept/reject -- [`contactRequestNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L495) L486 +- Contact connection: shows `ContactConnectionInfo` -- [`contactConnectionNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L530) L520 +- Notes folder: navigates to `ChatView` -- [`noteFolderNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L302) L298 + +### Navigation +- Uses `NavigationLink` (iOS 15) or programmatic navigation (iOS 16+) +- Sets `ChatModel.chatId` to trigger navigation +- `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation + +--- + +## 5. Filtering & Tags + +### Filter Tabs ([`TagListView`](../../Shared/Views/ChatList/TagListView.swift#L20)) + +**File**: `Shared/Views/ChatList/TagListView.swift` + +Horizontal scrolling tab bar below the navigation bar. Tabs: + +| Tab | Filter | Shows | +|-----|--------|-------| +| All | `nil` | All conversations | +| Unread | `.unread` | Conversations with unread messages | +| Favorites | `.presetTag(.favorites)` | Favorited conversations | +| Groups | `.presetTag(.groups)` | Group conversations | +| Contacts | `.presetTag(.contacts)` | Direct conversations | +| Business | `.presetTag(.business)` | Business conversations | +| Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | +| User tags | `.userTag(ChatTag)` | User-defined custom tags | + +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). + +### ChatTagsModel State + +Filtering state is managed by [`ChatTagsModel`](../../Shared/Model/ChatModel.swift#L189) (`ChatModel.swift` L183): + +```swift +class ChatTagsModel: ObservableObject { + @Published var userTags: [ChatTag] = [] + @Published var activeFilter: ActiveFilter? = nil + @Published var presetTags: [PresetTag: Int] = [:] // count per preset tag + @Published var unreadTags: [Int64: Int] = [:] // unread count per user tag +} +``` + +- `presetTags` counts are updated whenever `chats` changes via [`updateChatTags()`](../../Shared/Model/ChatModel.swift#L197) (L197) +- Tags with zero matching chats are auto-hidden +- Active filter is auto-cleared when its tag has no matching chats + +### Supporting Types + +| Type | File | Line | Description | +|------|------|------|-------------| +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | + +### Tag Management Commands +- `apiCreateChatTag(tag: ChatTagData)` -- create tag +- `apiSetChatTags(type:, id:, tagIds:)` -- assign tags to a chat +- `apiDeleteChatTag(tagId:)` -- delete tag +- `apiUpdateChatTag(tagId:, tagData:)` -- rename tag +- `apiReorderChatTags(tagIds:)` -- reorder tags + +--- + +## 6. Search + +Search is available via pull-down gesture or search button in the navigation bar. + +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) + +### Filtering Logic +- Filters `ChatModel.chats` by matching search text against: + - `chatInfo.displayName` (contact/group name) + - `chatInfo.localAlias` (local alias) + - `chatInfo.fullName` (full name) +- For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) + +### Search Results +- Matching chats are displayed in the same list format +- Results update as the user types (debounced) +- Clearing search restores the full filtered list + +--- + +## 7. Swipe Actions + +`ChatListNavLink` provides swipe actions on each row: + +### Leading Swipe (left-to-right) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Pin / Unpin | pin | [`toggleFavoriteButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L353) | 347 | `apiSetChatSettings` (favorite) | Always | +| Read / Unread | envelope | [`markReadButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L333) | 328 | `apiChatRead` / `apiChatUnread` | Always | + +### Trailing Swipe (right-to-left) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Mute / Unmute | bell.slash | [`toggleNtfsButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L372) | 365 | `apiSetChatSettings` (enableNtfs) | Always | +| Clear | trash | [`clearChatButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L393) | 385 | `apiClearChat` | Has messages | +| Delete | trash.fill | -- | -- | `apiDeleteChat` | Not active chat | +| Tag | tag | -- | -- | `apiSetChatTags` | Always | + +--- + +## 8. [`UserPicker`](../../Shared/Views/ChatList/UserPicker.swift#L10) {#8-userpicker} + +**File**: `Shared/Views/ChatList/UserPicker.swift` + +Triggered by tapping the user avatar in the navigation bar. Presented as a sheet with: + +| Section | Contents | +|---------|----------| +| User list | All non-hidden users with unread counts | +| Active user | Highlighted with checkmark | +| Actions | Settings, Your SimpleX address, User profiles | + +### User Switching +- Tapping a different user calls `apiSetActiveUser(userId:)` +- Triggers `apiGetChats` for the new user +- `ChatModel.currentUser` updates, causing full UI refresh +- Hidden users are not shown (require password entry via settings) + +--- + +## 9. Floating Action Button + +The FAB (floating action button) in the bottom-right corner opens the new chat flow: + +- Tap: opens `NewChatView` sheet for creating a new contact connection or group +- Shows options: Create link, Scan QR code, Paste link, Create group + +--- + +## Source Files + +| File | Path | Key struct | Line | +|------|------|------------|------| +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | +| Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | +| Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | +| User picker sheet | [`UserPicker.swift`](../../Shared/Views/ChatList/UserPicker.swift) | `UserPicker` | [9](../../Shared/Views/ChatList/UserPicker.swift#L10) | +| Getting started help | [`ChatHelp.swift`](../../Shared/Views/ChatList/ChatHelp.swift) | | | +| Contact request view | [`ContactRequestView.swift`](../../Shared/Views/ChatList/ContactRequestView.swift) | | | +| Contact connection info | [`ContactConnectionInfo.swift`](../../Shared/Views/ChatList/ContactConnectionInfo.swift) | | | +| Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | +| Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | +| One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md new file mode 100644 index 0000000000..b913287746 --- /dev/null +++ b/apps/ios/spec/client/chat-view.md @@ -0,0 +1,331 @@ +# SimpleX Chat iOS -- Chat View Module + +> Technical specification for the message rendering, chat item types, and context menu actions in the conversation view. +> +> Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView](#2-chatview) +3. [ChatItemView -- Message Routing](#3-chatitemview) +4. [Message Renderers](#4-message-renderers) +5. [Media Views](#5-media-views) +6. [Metadata & Info](#6-metadata--info) +7. [Context Menu Actions](#7-context-menu-actions) +8. [Selection Mode](#8-selection-mode) + +--- + +## 1. Overview + +The chat view module renders individual conversations. It consists of: + +- **ChatView** -- The main conversation screen with message list, compose bar, and navigation +- **ChatItemView** -- Router that dispatches each chat item to the appropriate renderer +- **Specialized renderers** -- FramedItemView (standard messages), EmojiItemView (emoji-only), CICallItemView (calls), event views, etc. +- **Media views** -- CIImageView, CIVideoView, CIVoiceView, CIFileView for attachments + +``` +ChatView +├── Message List (ScrollView / LazyVStack) +│ ├── ChatItemView (per message) +│ │ ├── FramedItemView (text/media bubbles) +│ │ │ ├── MsgContentView (text with markdown) +│ │ │ ├── CIImageView / CIVideoView / CIVoiceView +│ │ │ └── CIMetaView (timestamp, status) +│ │ ├── EmojiItemView (emoji-only messages) +│ │ ├── CICallItemView (call events) +│ │ ├── CIEventView (system events) +│ │ ├── CIGroupInvitationView (group invitations) +│ │ ├── DeletedItemView / MarkedDeletedItemView +│ │ └── CIInvalidJSONView (decode errors) +│ └── ... (more items) +├── ComposeView (message input) +└── Navigation bar (contact/group info) +``` + +--- + +## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3135) + +**File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) + +The main conversation view. Key responsibilities: + +### State +- Uses `ItemsModel.shared.reversedChatItems` for the primary message list +- `ChatModel.shared.chatId` identifies the active conversation +- Manages compose state, scroll position, keyboard visibility +- Tracks selection mode for multi-message actions + +### Message List +- Renders messages in a `ScrollViewReader` with `LazyVStack` +- Items are in reverse chronological order (newest at bottom) +- Supports infinite scroll: preloads older messages when scrolling up via `ItemsModel.preloadState` +- Handles pagination splits (`chatState.splits`) for non-contiguous loaded ranges + +### Navigation Bar +- Title: contact name / group name with connection status indicator +- Trailing button: navigates to [`ChatInfoView`](../../Shared/Views/Chat/ChatInfoView.swift#L93) (direct) or [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) (group) +- Search button: toggles in-chat message search + +### Scroll Behavior +- Auto-scrolls to bottom on new sent/received messages (if already near bottom) +- "Scroll to bottom" floating button when scrolled up +- `openAroundItemId` support: scrolls to a specific message (e.g., from search or notification) + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ChatView.swift#L76) | L74 | Main view body | +| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L675) | L672 | Initializes chat view state on appear | +| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L821) | L814 | Builds the scrollable message list | +| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L735) | L731 | Scrolls to a specific message by ID | +| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L769) | L764 | In-chat search toolbar UI | +| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1087 | Handles search query changes | +| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1519 | Loads chat items with pagination | +| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L807) | L801 | Filters items by content type | +| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1264 | Audio/video call toolbar button | +| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1284 | Search toggle toolbar button | +| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1352 | Group add-members toolbar button | +| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1409 | Forwards batch-selected messages | +| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1401 | Deletes batch-selected messages | +| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1559 | Reacts to chat items model changes | +| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1292 | Content filter dropdown menu | + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1586 | Wraps each chat item with context menu | +| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2712) | L2697 | Manages scroll-to-bottom button state | +| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2899) | L2882 | Reaction picker context menu | +| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L2997) | L2980 | Mute/unmute notifications button | +| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3049) | L3031 | Enum for message content filter types | +| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2795) | L2779 | Deletes messages with confirmation | +| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2842) | L2826 | Archives report messages | + +--- + +## [3. ChatItemView](../../Shared/Views/Chat/ChatItemView.swift#L42) + +**File**: [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) + +Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type: + +### Content Types (CIContent enum) + +| Content Type | Renderer | Line | Description | +|-------------|----------|------|-------------| +| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L13 | Standard sent/received text+media message | +| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L13 | Locally deleted message placeholder | +| `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) | +| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L13 | Message integrity error | +| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L15 | Decryption failure | +| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L13 | Group invite | +| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Group system event | +| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Connection event | +| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L13 | Feature toggle event | +| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L13 | Preference change | +| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L13 | Failed to decode | + +### Bubble Direction +- Sent messages: aligned right, sender-colored bubble +- Received messages: aligned left, receiver-colored bubble +- Events/system messages: centered, no bubble + +### Appearance Dependencies +Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depend on the previous and next items for visual decisions: +- Whether to show the sender name (group messages, different sender than previous) +- Whether to show the tail on the bubble (last consecutive message from same sender) +- Date separator between messages on different days + +`ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes. + +--- + +## 4. Message Renderers + +### [FramedItemView](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) + +The standard message bubble. Renders: +- Quote/reply preview (if replying to another message) +- Forwarded indicator +- Sender name (in groups) +- Message content (`MsgContentView` with markdown) +- Attached media (image, video, voice, file, link preview) +- Reaction summary bar +- Metadata line (`CIMetaView`) + +### [EmojiItemView](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) + +Renders emoji-only messages (messages containing only emoji characters) in a larger font without a bubble background. + +### [MsgContentView](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) + +**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) + +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). + +### DeletedItemView / MarkedDeletedItemView + +**Files**: [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) + +- [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14): Placeholder for locally deleted messages +- [`MarkedDeletedItemView`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14): Shows "message deleted" with optional moderation info (who deleted, when) + +### [CIEventView](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) + +Centered system event text for group events (member joined, left, role changed) and connection events. + +### [CIGroupInvitationView](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) + +Renders group invitation with accept/reject buttons. + +--- + +## 5. Media Views + +### [CIImageView](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) + +Renders inline images. Tapping opens `FullScreenMediaView` for zooming/panning. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [CIVideoView](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) + +Renders video thumbnails with play button. Tapping opens video player. Videos above auto-receive threshold require manual download. + +### CIVoiceView / FramedCIVoiceView + +**Files**: [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) + +Renders voice messages with waveform visualization, play/pause control, and duration. [`FramedCIVoiceView`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) is the version inside a message bubble with additional context. + +### [CIFileView](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) + +Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. + +### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) + +Renders link preview cards with OpenGraph metadata (title, description, image). + +### [AnimatedImageView](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) + +**File**: [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) + +Renders animated GIF images. + +### [FullScreenMediaView](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) + +Full-screen media viewer with zoom, pan, and share actions. Supports images and videos. + +--- + +## 6. Metadata & Info + +### [CIMetaView](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) + +Displays message metadata inline at the bottom of the bubble: +- Timestamp (sent time) +- Delivery status icon (sending, sent, delivered, read, error) +- Edit indicator (pencil icon if message was edited) +- Disappearing message timer (if timed message) + +### [ChatItemInfoView](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) + +**File**: [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) + +Detailed message information sheet (accessed via long-press menu "Info"): +- Full delivery history (per-member delivery status in groups) +- Edit history (all previous versions of edited messages) +- Forward chain info +- Message timestamps (created, updated, deleted) + +--- + +## 7. Context Menu Actions + +Long-pressing a message shows a context menu with actions based on message type and ownership: + +| Action | Available For | API Command | +|--------|--------------|-------------| +| Reply | All messages | Sets compose state to `.replying` | +| Forward | Sent/received content messages | `apiForwardChatItems` | +| Copy | Text messages | Copies to clipboard | +| Edit | Own sent messages (within edit window) | `apiUpdateChatItem` | +| Delete for me | All messages | `apiDeleteChatItem(mode: .cidmInternal)` | +| Delete for everyone | Own sent messages | `apiDeleteChatItem(mode: .cidmBroadcast)` | +| Moderate | Group admin/owner for others' messages | `apiDeleteMemberChatItem` | +| React | Content messages (if reactions enabled) | `apiChatItemReaction` | +| Select | All messages | Enters multi-select mode | +| Info | All messages | Opens [`ChatItemInfoView`](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| Save | Media messages | Saves to photo library / files | +| Share | Content messages | iOS share sheet | + +--- + +## 8. Selection Mode + +Multi-selection mode allows batch operations on messages: + +- Enter via long-press "Select" action +- Toggle individual messages with tap +- Toolbar appears with batch actions: Delete, Forward +- Exit via cancel button or completing batch action + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L17](../../Shared/Views/Chat/ChatView.swift#L18) | +| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L41](../../Shared/Views/Chat/ChatItemView.swift#L42) | +| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | +| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | +| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | +| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | +| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | +| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | +| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | +| Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | +| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | +| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L12](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | +| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | +| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | +| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L27](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | +| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | +| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | +| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | +| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | +| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | +| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L10](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | +| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | +| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md new file mode 100644 index 0000000000..03116ddf6b --- /dev/null +++ b/apps/ios/spec/client/compose.md @@ -0,0 +1,355 @@ +# SimpleX Chat iOS -- Message Composition Module + +> Technical specification for the compose bar, attachment types, reply/edit/forward modes, voice recording, and mentions. +> +> Related specs: [Chat View](chat-view.md) | [File Transfer](../services/files.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeView](#2-composeview) +3. [ComposeState Machine](#3-composestate-machine) +4. [Attachment Types](#4-attachment-types) +5. [Reply Mode](#5-reply-mode) +6. [Edit Mode](#6-edit-mode) +7. [Forward Mode](#7-forward-mode) +8. [Live Messages](#8-live-messages) +9. [Voice Recording](#9-voice-recording) +10. [Link Previews](#10-link-previews) +11. [Mentions](#11-mentions) + +--- + +## 1. Overview + +The compose module handles all message creation, editing, and forwarding. It sits at the bottom of `ChatView` and adapts its UI based on the current compose state. + +``` +ComposeView +├── Context banner (reply quote / edit indicator / forward indicator) +├── Attachment preview (image / video / file / voice waveform) +├── Text input (NativeTextEditor with markdown support) +├── Action buttons +│ ├── Attachment menu (camera, photo library, file picker) +│ ├── Voice record button (hold or toggle) +│ └── Send button (or live message indicator) +└── Link preview (auto-generated when URL detected) +``` + +--- + +## 2. [ComposeView](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) (`struct ComposeView: View`) + +**File**: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` + +### Layout +- Fixed at the bottom of ChatView +- Expands vertically as text input grows (up to a maximum height) +- Context banner appears above the text field when in reply/edit/forward mode +- Attachment preview appears between context banner and text field + +### Key Properties +- Reads `ChatModel.shared.draft` / `draftChatId` for persisted drafts +- Manages its own internal compose state +- Coordinates with `ChatView` for scroll-to-bottom behavior on send + +### Send Flow +1. User taps send button +2. ComposeView constructs `[ComposedMessage]` from current state +3. Calls `apiSendMessages(type:, id:, scope:, live:, ttl:, composedMessages:)` +4. On success: clears compose state, scrolls to bottom +5. On failure: shows error alert, preserves compose state + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L369) | L360 | Main view body | +| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L693) | L683 | Builds the send-message UI | +| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1106) | L1091 | Entry point: initiates send | +| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1115) | L1099 | Async send implementation | +| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1467) | L1446 | Resets compose state after send | +| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L893) | L882 | Adds media attachment | +| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L866) | L856 | Checks link preview before connect | +| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L754) | L744 | Builds commands menu button | + +### Draft Persistence + +| Function | Line | Description | +|----------|------|-------------| +| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1481) | L1459 | Saves compose state to `ChatModel.draft` | +| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1487) | L1464 | Clears persisted draft | + +- When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId` +- When returning to the same chat, draft is restored +- Drafts are not persisted across app restarts + +--- + +## 3. [ComposeState](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) Machine (`struct ComposeState`) + +The compose bar operates as a state machine with these primary states: + +``` + ┌──────────┐ + │ .empty │ ← initial / after send + └─────┬────┘ + │ user types / attaches / quotes + v + ┌─────────────────────────────────────┐ + │ │ + ┌────▼────┐ ┌──────────────┐ ┌──────────▼───┐ + │ .text │ │ .mediaPending │ │ .voiceRecording │ + └─────────┘ └──────────────┘ └───────────────┘ + │ │ + │ long-press reply│ tap edit + v v + ┌──────────┐ ┌──────────┐ ┌───────────┐ + │ .replying │ │ .editing │ │ .forwarding│ + └──────────┘ └──────────┘ └───────────┘ +``` + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L10 | Preview variants (image, voice, file, etc.) | +| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L18 | Context item for reply/quote | +| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L26 | Recording state enum | +| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L40 | Full compose state struct | +| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L93 | Copy compose state with overrides | +| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L113 | Format mention display name | +| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L260 | Build preview from chat item | +| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L280 | Upload content variants | + +### States + +| State | Description | UI | +|-------|-------------|-----| +| `.empty` | No input, no attachments | Placeholder text, attachment button | +| `.text` | Text entered, no attachments | Send button visible | +| `.mediaPending` | Media/file selected, optionally with text | Preview visible, send button | +| `.voiceRecording` | Voice recording in progress | Waveform animation, stop/send | +| `.replying` | Replying to a specific message | Quote banner above input | +| `.editing` | Editing a previously sent message | Edit banner, pre-filled text | +| `.forwarding` | Forwarding selected messages | Forward banner, item previews | + +### Transitions + +| From | Trigger | To | +|------|---------|-----| +| `.empty` | User types text | `.text` | +| `.empty` | User selects media | `.mediaPending` | +| `.empty` | User holds voice button | `.voiceRecording` | +| `.empty` | User long-presses message "Reply" | `.replying` | +| `.empty` | User long-presses message "Edit" | `.editing` | +| `.empty` | User selects "Forward" | `.forwarding` | +| Any | User taps send | `.empty` | +| Any | User taps cancel (X) | `.empty` | + +--- + +## 4. Attachment Types + +### [ComposeImageView](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) + +**File**: [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) (struct at L12) + +Preview of selected image(s) before sending. Shows thumbnail with remove button. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [ComposeFileView](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) + +**File**: [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) (struct at L11) + +Preview of selected file or video. Shows filename, size, and remove button. Videos show a thumbnail frame. + +### [ComposeVoiceView](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) + +**File**: [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) (struct at L26) + +Voice message recording/playback preview. Shows waveform visualization, duration, and play/delete buttons. + +### Attachment Menu Options + +| Option | Picker | Max Size | Transfer Method | +|--------|--------|----------|-----------------| +| Camera photo | UIImagePickerController | Compressed to 255KB | Inline in SMP message | +| Photo library | PHPickerViewController | Compressed to 255KB | Inline or XFTP | +| Video | PHPickerViewController | Up to 1GB | XFTP | +| File | UIDocumentPickerViewController | Up to 1GB | XFTP | + +--- + +## 5. Reply Mode + +Activated via long-press context menu "Reply" on any message. + +### UI +- Quote banner above text input showing original message preview +- X button to cancel reply +- Original message reference stored in compose state + +### API +- Reply is sent as part of `ComposedMessage` with `quotedItemId` parameter +- `apiSendMessages(composedMessages: [ComposedMessage(quotedItemId: originalItem.id, ...)])` + +--- + +## 6. Edit Mode + +Activated via long-press context menu "Edit" on own sent messages (within the edit window). + +### UI +- Edit banner above text input with pencil icon +- Text field pre-filled with original message content +- Send button changes to "Save" / checkmark + +### API +- `apiUpdateChatItem(type:, id:, scope:, itemId:, updatedMessage:, live:)` +- Response: `ChatResponse1.chatItemUpdated(user:, chatItem:)` + +### Constraints +- Only own sent messages can be edited +- Edit is available within a server-defined time window +- Edited messages show a pencil indicator in `CIMetaView` +- Edit history is visible in `ChatItemInfoView` + +--- + +## 7. Forward Mode + +Activated via long-press context menu "Forward" or via multi-select toolbar. + +### Flow +1. User selects "Forward" on message(s) +2. `apiPlanForwardChatItems(fromChatType:, fromChatId:, fromScope:, itemIds:)` is called to plan +3. Response: `ChatResponse1.forwardPlan(user:, chatItemIds:, forwardConfirmation:)` +4. User selects destination chat +5. `apiForwardChatItems(toChatType:, toChatId:, toScope:, fromChatType:, fromChatId:, fromScope:, itemIds:, ttl:)` executes the forward +6. Forwarded messages appear with a forwarded indicator + +### ForwardConfirmation +The plan response may include a `forwardConfirmation` requiring user confirmation (e.g., forwarding to a less secure chat). + +--- + +## 8. [Live Messages](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L36) (`struct LiveMessage`) + +Optional feature where the recipient sees typing in real-time. + +### How It Works +- User enables live message mode (lightning icon) +- As user types, `apiSendMessages(live: true)` is called repeatedly +- Each call sends the current text as an update to the same message +- Recipient sees the message being composed in real-time +- Final send marks the message as complete + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L922) | L910 | Initiates a live message | +| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L940) | L927 | Sends incremental live update | +| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L959) | L945 | Determines text diff to send | +| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L964) | L950 | Truncates text at word boundary | + +### API +- Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message +- Updates: `apiUpdateChatItem(live: true)` -- updates content as user types +- Final: `apiUpdateChatItem(live: false)` -- marks as complete + +--- + +## 9. Voice Recording + +### Recording Flow +1. User taps (or holds) the microphone button +2. `AVAudioRecorder` starts recording in compressed format +3. Waveform visualization shows real-time audio levels +4. User taps stop (or releases hold) to finish recording +5. Preview with playback shown in compose area +6. User taps send to deliver + +### Voice Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1382) | L1365 | Begins audio recording | +| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1423) | L1405 | Stops recording, shows preview | +| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1434) | L1415 | Enables voice messages for contact | +| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1441) | L1422 | Updates state after recording finishes | +| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1453) | L1434 | Cancels in-progress recording | +| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1460) | L1440 | Cancels and cleans up recording file | + +### Constraints +- Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes) +- Auto-receive threshold: `MAX_VOICE_SIZE_AUTO_RCV = 522,240` bytes (510KB) +- Compressed audio format for small file sizes + +### Audio Management +- [`AudioRecorder`](../../Shared/Model/AudioRecPlay.swift#L14) (`Shared/Model/AudioRecPlay.swift` L14) manages recording and playback +- `ChatModel.stopPreviousRecPlay` coordinates exclusive audio playback (only one audio source plays at a time) + +--- + +## 10. [Link Previews](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) (`ComposeLinkView`) + +**File**: [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) (struct at L13) + +### Auto-Detection +- As user types, URLs in the text are detected +- When a URL is found, `ComposeLinkView` fetches OpenGraph metadata +- Preview card shows title, description, and thumbnail image + +### Link Preview Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1495) | L1471 | Triggers link preview loading | +| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1515) | L1490 | Extracts URLs from formatted text | +| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) | L1501 | Checks if URL is a SimpleX link | +| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1530) | L1505 | Cancels pending preview | +| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1542) | L1516 | Fetches OpenGraph metadata | +| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1559) | L1533 | Resets preview state | + +### Behavior +- Only the first URL in the message generates a preview +- Preview can be dismissed by the user +- Link preview data is included in the `ComposedMessage` sent to the core +- Toggle in privacy settings to disable auto-preview generation + +--- + +## 11. Mentions + +In group chats, typing `@` triggers member name autocomplete: + +### Flow +1. User types `@` in the text field +2. Autocomplete dropdown appears with matching group members +3. User selects a member +4. `@displayName` is inserted into the text +5. Mention is rendered with special formatting in the sent message + +### Data +- Group members loaded from `ChatModel.groupMembers` +- Mention metadata included in `ComposedMessage` + +--- + +## Source Files + +| File | Path | Struct/Class | Line | +|------|------|--------------|------| +| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L321](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | +| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L14](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | +| Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) | +| File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) | +| Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) | +| Link preview | [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) | `ComposeLinkView` | [L13](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) | +| Audio recording | [`AudioRecPlay.swift`](../../Shared/Model/AudioRecPlay.swift) | `AudioRecorder` | [L14](../../Shared/Model/AudioRecPlay.swift#L14) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md new file mode 100644 index 0000000000..e755115827 --- /dev/null +++ b/apps/ios/spec/client/navigation.md @@ -0,0 +1,312 @@ +# SimpleX Chat iOS -- Navigation Architecture + +> Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay. +> +> Related specs: [Chat List](chat-list.md) | [Chat View](chat-view.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ContentView.swift`](../../Shared/ContentView.swift) | [`NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | [`SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | [`OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | [`UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Root View -- ContentView](#2-root-view) +3. [Navigation Stack](#3-navigation-stack) +4. [Sheet Presentation](#4-sheet-presentation) +5. [Deep Linking](#5-deep-linking) +6. [Call Overlay](#6-call-overlay) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) + +--- + +## 1. Overview + +The app's navigation follows a hierarchical model with a single navigation stack rooted in `ContentView`. Modal sheets and full-screen overlays augment the primary navigation path. + +``` +SimpleXApp +└── ContentView (root) + ├── Authentication gate (LocalAuthView / SetAppPasscodeView) + ├── Onboarding flow (if first launch / migration) + ├── Main content + │ └── NavigationStack / NavigationView + │ ├── ChatListView (root of stack) + │ │ ├── ChatView (pushed) + │ │ │ ├── ChatInfoView / GroupChatInfoView (pushed) + │ │ │ └── ChatItemInfoView (pushed) + │ │ └── ContactConnectionInfo (pushed) + │ └── Settings views (pushed) + ├── Sheets (modal) + │ ├── UserPicker + │ ├── NewChatView + │ ├── WhatsNew / Notices + │ └── Settings sub-views + └── Overlays (always on top) + ├── Active call banner (when call active) + └── ActiveCallView (full-screen call) +``` + +--- + +## 2. Root View -- [`ContentView`](../../Shared/ContentView.swift#L24) + +**File**: [`Shared/ContentView.swift`](../../Shared/ContentView.swift) + +`ContentView` is the root view injected by `SimpleXApp`. It manages: + +### [Environment](../../Shared/ContentView.swift#L25-L37) +- `@EnvironmentObject var chatModel: ChatModel` +- `@EnvironmentObject var theme: AppTheme` +- `@Environment(\.scenePhase) var scenePhase` + +### [Key State](../../Shared/ContentView.swift#L35-L52) +| Property | Type | Purpose | +|----------|------|---------| +| [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) | `Bool` | Passed at init to avoid re-render timing issues | +| [`automaticAuthenticationAttempted`](../../Shared/ContentView.swift#L38) | `Bool` | Whether biometric auth was auto-attempted | +| [`waitingForOrPassedAuth`](../../Shared/ContentView.swift#L51) | `Bool` | Whether auth gate should show | +| [`chatListUserPickerSheet`](../../Shared/ContentView.swift#L52) | `UserPickerSheet?` | Active user picker sheet | + +### [View Selection Logic](../../Shared/ContentView.swift#L60-L80) + +```swift +// Simplified decision tree in ContentView.body: +if !prefPerformLA || accessAuthenticated { + contentView() // Main app content +} else { + lockButton() // Authentication required +} +``` + +The [`contentView()`](../../Shared/ContentView.swift#L169) function further decides: +- If `chatModel.onboardingStage != .onboardingComplete`: show [onboarding](../../Shared/ContentView.swift#L174) +- If `chatModel.migrationState != nil`: show migration UI +- Otherwise: show `ChatListView` in a navigation container + +--- + +## 3. Navigation Stack + +### iOS Version Compatibility + +**File**: [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) + +The app supports iOS 15+ and uses a compatibility wrapper ([`NavStackCompat`](../../Shared/Views/Helpers/NavStackCompat.swift#L11)): + +```swift +// NavStackCompat provides: +// - NavigationStack (iOS 16+): programmatic navigation via NavigationPath +// - NavigationView (iOS 15): classic NavigationLink-based navigation +``` + +### Primary Navigation Path + +``` +ChatListView + │ + ├─[tap chat]─→ ChatView + │ │ + │ ├─[tap info]─→ ChatInfoView (direct) + │ │ └─→ VerifyCodeView, etc. + │ │ + │ ├─[tap info]─→ GroupChatInfoView (group) + │ │ ├─→ GroupMemberInfoView + │ │ ├─→ GroupProfileView + │ │ └─→ GroupLinkView + │ │ + │ └─[tap message info]─→ ChatItemInfoView + │ + ├─[tap connection]─→ ContactConnectionInfo + │ + └─[settings]─→ SettingsView + ├─→ NotificationsView + ├─→ NetworkAndServers + ├─→ AppearanceSettings + ├─→ PrivacySettings + ├─→ DatabaseView + └─→ UserProfilesView +``` + +### Navigation Trigger + +Chat navigation is triggered by setting `ChatModel.chatId`: + +```swift +// In ChatListNavLink: +ItemsModel.shared.loadOpenChat(chatId) { + // This sets ChatModel.chatId = chatId after a 250ms delay + // allowing navigation animation to start smoothly +} +``` + +--- + +## 4. Sheet Presentation + +Sheets are presented modally on top of the navigation stack: + +| Sheet | Trigger | Content | +|-------|---------|---------| +| UserPicker | Tap user avatar in nav bar | User list, settings shortcuts | +| [`NewChatView`](../../Shared/Views/NewChat/NewChatView.swift#L78) | Tap FAB / "+" button | Create link, scan QR, paste link, new group | +| WhatsNew | App update detected | Release notes | +| AddGroupView | "New Group" action | Group creation wizard | +| ConnectDesktopView | Settings > Desktop | Remote desktop pairing | +| MigrateFromDevice | Settings > Migration | Device export | +| MigrateToDevice | Onboarding migration | Device import | +| [LocalAuthView](../../Shared/ContentView.swift#L95) | App foreground after background | Biometric/passcode auth | + +### Sheet Management + +Sheets use SwiftUI `.sheet(item:)` or `.sheet(isPresented:)` modifiers on `ContentView` and `ChatListView`. Some sheets use the centralized [`AppSheetState.shared`](../../Shared/ContentView.swift#L29) observable for coordination: + +```swift +class AppSheetState: ObservableObject { + static let shared = AppSheetState() + var scenePhaseActive: Bool = false + // ... sheet state coordination +} +``` + +--- + +## 5. Deep Linking + +### Notification Deep Link + +When the user taps a notification: + +1. `NtfManager.processNotificationResponse()` extracts the `chatId` from notification payload +2. If a different user: calls `changeActiveUser(userId:)` +3. Sets `ChatModel.chatId = chatId` to navigate to the conversation +4. If the app was in background: the notification response is stored in `ChatModel.notificationResponse` and processed when the app becomes active + +### [URL Deep Link](../../Shared/ContentView.swift#L281) + +SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Shared/ContentView.swift#L439): + +```swift +.onOpenURL { url in + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url // Process immediately + } else { + chatModel.appOpenUrlLater = url // Process when active + } +} +``` + +URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1169). + +### Call Deep Link + +Call invitations from notifications: +1. `NtfManager` detects `ntfActionAcceptCall` action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept)` +3. `ContentView` picks up the pending action and initiates the call + +--- + +## 6. Call Overlay + +The call UI overlays the entire app when a call is active: + +### [Call Banner](../../Shared/ContentView.swift#L203) + +When `ChatModel.activeCall != nil` and call is in connecting/active state: +- A banner appears at the top of ContentView (height: [`callTopPadding = 40`](../../Shared/ContentView.swift#L54)) +- Shows contact name, call duration, tap to return to full-screen call +- Main content is padded down to accommodate the banner + +### [Full-Screen Call View](../../Shared/ContentView.swift#L185) + +When `ChatModel.showCallView == true`: +- `ActiveCallView` covers the entire screen as a ZStack overlay +- Contains local/remote video, controls (mute, camera, speaker, end) +- PiP mode: `ChatModel.activeCallViewIsCollapsed` collapses to mini view +- Call view is always rendered on top of navigation and sheets + +```swift +// In ContentView.allViews(): +ZStack { + contentView() + .padding(.top, showCallArea ? callTopPadding : 0) + + if showCallArea, let call = chatModel.activeCall { + VStack { + activeCallInteractiveArea(call) + Spacer() + } + } + + if chatModel.showCallView, let call = chatModel.activeCall { + callView(call) // Full screen overlay + } +} +``` + +--- + +## 7. Authentication Gate + +### [Local Authentication](../../Shared/ContentView.swift#L359) + +When [`DEFAULT_PERFORM_LA`](../../Shared/ContentView.swift#L44) is enabled: + +1. App enters background: `chatModel.contentViewAccessAuthenticated = false` +2. App returns to foreground: `ContentView` shows [`lockButton()`](../../Shared/ContentView.swift#L238) instead of content +3. User taps lock button: [`LocalAuthView`](../../Shared/ContentView.swift#L95) presented +4. On successful auth: `chatModel.contentViewAccessAuthenticated = true`, content revealed + +### Authentication Methods +- Face ID / Touch ID (via `LocalAuthentication` framework) +- Custom numeric passcode +- Custom alphanumeric passcode + +### [Extended Authentication](../../Shared/ContentView.swift#L351) +- After successful auth, a grace period prevents re-auth for brief background/foreground cycles ([`unlockedRecently()`](../../Shared/ContentView.swift#L351)) +- [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) is computed at `ContentView.init` to avoid render-time race conditions +- The `enteredBackgroundAuthenticated` timestamp tracks when the app was last authenticated in background + +--- + +## 8. [Onboarding Flow](../../Shared/Views/Onboarding/OnboardingView.swift#L13) + +First-launch experience controlled by [`ChatModel.onboardingStage`](../../Shared/Views/Onboarding/OnboardingView.swift#L46): + +```swift +enum OnboardingStage: String, Identifiable { + case step1_SimpleXInfo // Welcome screen + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // Choose server operators + case step4_SetNotificationsMode // Set notification preferences + case onboardingComplete // Normal operation +} +``` + +Each stage is a dedicated view presented in place of `ChatListView` within [`ContentView`](../../Shared/ContentView.swift#L174). + +Migration state (`ChatModel.migrationState != nil`) takes precedence over onboarding. + +--- + +## Source Files + +| File | Path | +|------|------| +| Root view | [`Shared/ContentView.swift`](../../Shared/ContentView.swift) | +| App entry point | `Shared/SimpleXApp.swift` | +| Navigation compat | [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) | +| Chat list (nav root) | `Shared/Views/ChatList/ChatListView.swift` | +| Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` | +| User picker | `Shared/Views/ChatList/UserPicker.swift` | +| New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | +| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | +| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) | +| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | +| Active call view | `Shared/Views/Call/ActiveCallView.swift` | +| Local auth view | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Notification manager | `Shared/Model/NtfManager.swift` | diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md new file mode 100644 index 0000000000..9e5adfcb64 --- /dev/null +++ b/apps/ios/spec/database.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- Database & Storage + +**Source:** [`FileUtils.swift`](../SimpleXChat/FileUtils.swift) + +> Technical specification for the database architecture, encryption, file storage, and export/import functionality. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Product Overview](../product/README.md) + +--- + +## Table of Contents + +1. [Database Overview](#1-database-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [App Group Sharing](#8-app-group-sharing) + +--- + +## 1. Database Overview + +SimpleX Chat uses two SQLite databases managed entirely by the Haskell core. The iOS Swift layer never reads or writes directly to the databases -- all data access goes through the FFI command/response API. + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat DB | `_chat.db` | Messages, contacts, groups, user profiles, files, tags, preferences, call history | +| Agent DB | `_agent.db` | SMP agent connections, cryptographic keys, message queues, server state, XFTP chunks | + +Both databases are initialized and migrated via the C FFI function `chat_migrate_init_key()`, which applies pending migrations and returns a `chat_ctrl` pointer. + +--- + +## 2. Database Files & Paths + +### [Path Resolution](../SimpleXChat/FileUtils.swift#L63-L73) (FileUtils.swift) + +```swift +let DB_FILE_PREFIX = "simplex_v1" + +// Database path depends on container preference +func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX) + : getLegacyDatabasePath() +} + +// Full database file paths: +// Chat: {container}/simplex_v1_chat.db +// Agent: {container}/simplex_v1_agent.db +``` + +### [File Constants](../SimpleXChat/FileUtils.swift#L38-L44) + +```swift +let CHAT_DB: String = "_chat.db" +let AGENT_DB: String = "_agent.db" +private let CHAT_DB_BAK: String = "_chat.db.bak" +private let AGENT_DB_BAK: String = "_agent.db.bak" +``` + +### Container Locations + +See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGroupContainerDirectory()`](../SimpleXChat/FileUtils.swift#L52). + +| Container | Path | Used When | +|-----------|------|-----------| +| App Group | `FileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)` | Default (shared with NSE) | +| Documents | `FileManager.urls(for: .documentDirectory)` | Legacy installations | + +The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`). + +--- + +## 3. Haskell Store Modules + +All database operations are implemented in Haskell. Key store modules (paths relative to repo root): + +| Module | Path | Size | Description | +|--------|------|------|-------------| +| Messages | `src/Simplex/Chat/Store/Messages.hs` | ~178KB | Message CRUD, pagination, search, reactions, delivery receipts | +| Groups | `src/Simplex/Chat/Store/Groups.hs` | ~126KB | Group CRUD, member management, roles, links, invitations | +| Direct | `src/Simplex/Chat/Store/Direct.hs` | ~52KB | Direct contact connections, contact requests. See `createDirectChat` in `Store/Direct.hs` | +| Files | `src/Simplex/Chat/Store/Files.hs` | ~43KB | File transfer state, XFTP chunks, inline files | +| Profiles | `src/Simplex/Chat/Store/Profiles.hs` | ~42KB | User profiles, contact profiles, incognito profiles | +| Connections | `src/Simplex/Chat/Store/Connections.hs` | ~17KB | Connection lifecycle, queue management | + +### Data Model (key tables) + +``` +users -- User profiles (userId, displayName, fullName, image, ...) +contacts -- Contact records (contactId, userId, localDisplayName, ...) +groups -- Group records (groupId, userId, groupProfile, ...) +group_members -- Group membership (groupMemberId, groupId, memberId, role, ...) +messages -- Message records (messageId, chatItemId, msgBody, ...) +chat_items -- Chat items (chatItemId, chatType, chatId, content, ...) +files -- File transfer records (fileId, chatItemId, fileName, fileSize, ...) +connections -- SMP connections (connId, agentConnId, ...) +chat_tags -- User-defined chat tags +chat_tags_chats -- Tag-to-chat assignments +``` + +--- + +## 4. Migrations + +Database migrations are managed by the Haskell core. Migration files are located in: + +``` +src/Simplex/Chat/Store/SQLite/Migrations/ +``` + +Migrations are numbered sequentially starting from `M20220101` through `M20260122` (200+ migrations). Each migration is a Haskell module containing SQL statements for schema changes. + +The migration process: +1. `chat_migrate_init_key()` is called with the database path +2. Haskell reads the current schema version from the database +3. Pending migrations are applied in order +4. If migration fails, the function returns an error string (not a `chat_ctrl`) +5. On success, a `chat_ctrl` pointer is returned + +Migration results are decoded in Swift as `DBMigrationResult`: +- `.ok` -- migrations applied successfully +- `.invalidConfirmation` -- migration requires user confirmation +- `.errorNotADatabase(dbFile:)` -- file is not a valid SQLite database +- `.errorMigration(dbFile:, migrationError:)` -- migration failed +- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration +- `.errorKeychain` -- keychain access failed +- `.unknown(json:)` -- unrecognized response + +--- + +## 5. Database Encryption + +### Encryption Configuration + +Database encryption uses SQLCipher (AES-256) and is managed through the API: + +```swift +// Set or change encryption +ChatCommand.apiStorageEncryption(config: DBEncryptionConfig) + +// Test if a key is correct +ChatCommand.testStorageEncryption(key: String) +``` + +`DBEncryptionConfig` contains: +- `currentKey: String` -- current encryption key (empty if unencrypted) +- `newKey: String` -- new encryption key (empty to decrypt) + +### Key Storage + +The encryption key is stored in the iOS Keychain via `kcDatabasePassword`: +- On first launch with encryption, the key is generated and stored +- The `storeDBPassphraseGroupDefault` flag controls whether the key is auto-stored +- If the user opts out of auto-storage, they must enter the key on each launch + +### UI + +- [`DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) -- Encryption settings UI +- [`DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) -- Database management UI (size, export, import, encryption) + +--- + +## 6. File Storage + +### Directory Structure + +``` +{App Container}/ +├── Documents/ +│ ├── app_files/ -- Downloaded and sent files +│ ├── temp_files/ -- Temporary files during transfer +│ └── assets/wallpapers/ -- Custom wallpaper images +├── {App Group Container}/ +│ ├── simplex_v1_chat.db -- Chat database +│ ├── simplex_v1_agent.db -- Agent database +│ └── ... +``` + +### [File Size Constants](../SimpleXChat/FileUtils.swift#L18-L36) (FileUtils.swift) + +```swift +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB -- inline image compression target +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive images +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive voice +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB -- auto-receive video +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB -- max XFTP transfer +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB -- max SMP inline +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit for local files +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes +``` + +### CryptoFile (Encrypted File Storage) + +When `apiSetEncryptLocalFiles(enable: true)` is set, files stored on device are AES-encrypted: + +- Encryption/decryption uses `chat_encrypt_file` / `chat_decrypt_file` C FFI functions +- Each file gets a unique key and nonce stored alongside the file reference +- The `CryptoFile` type wraps `(filePath: String, cryptoArgs: CryptoFileArgs?)` where `CryptoFileArgs` contains `(fileKey: String, fileNonce: String)` + +### [File Path Helpers](../SimpleXChat/FileUtils.swift#L219-L221) + +```swift +public func getDocumentsDirectory() -> URL // Standard documents dir +public func getGroupContainerDirectory() -> URL // App group container +func getAppFilesDirectory() -> URL // {appDir}/app_files/ +func getTempFilesDirectory() -> URL // {appDir}/temp_files/ +func getWallpaperDirectory() -> URL // {appDir}/assets/wallpapers/ +``` + +See also [`saveFile()`](../SimpleXChat/FileUtils.swift#L226), [`removeFile()`](../SimpleXChat/FileUtils.swift#L243), and [`getMaxFileSize()`](../SimpleXChat/FileUtils.swift#L276). + +### [Cleanup](../SimpleXChat/FileUtils.swift#L86-L116) + +- Files are deleted when their associated `ChatItem` is deleted. See [`cleanupFile()`](../SimpleXChat/FileUtils.swift#L267) and [`cleanupDirectFile()`](../SimpleXChat/FileUtils.swift#L260). +- Timed message expiry triggers file deletion +- [`deleteAppDatabaseAndFiles()`](../SimpleXChat/FileUtils.swift#L86) removes all databases, files, temp files, and wallpapers +- [`deleteAppFiles()`](../SimpleXChat/FileUtils.swift#L108) removes only the files directory (preserving databases) + +--- + +## 7. Export & Import + +### Export + +```swift +ChatCommand.apiExportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveExported(archiveErrors: [ArchiveError]) +``` + +`ArchiveConfig` specifies: +- `archivePath: String` -- destination path for the archive +- `disableCompression: Bool?` -- optional flag to skip compression + +The archive contains both databases and optionally files. The Haskell core handles the actual export, creating a ZIP archive. + +### Import + +```swift +ChatCommand.apiImportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveImported(archiveErrors: [ArchiveError]) +``` + +Import replaces the current databases with the archive contents. The app must be restarted after import. + +### Archive Errors + +`ArchiveError` is an array returned with both export and import results, listing any non-fatal issues encountered (e.g., missing files, corrupt entries). + +--- + +## 8. App Group Sharing + +### Shared Access Model + +The main app and NSE share database access through the iOS App Group container: + +``` +Main App ──┐ + ├── {App Group}/simplex_v1_chat.db + ├── {App Group}/simplex_v1_agent.db +NSE ────────┘ +``` + +### Coordination + +- Both processes can initialize their own `chat_ctrl` instance pointing to the same database files +- SQLite WAL mode allows concurrent reads +- Write coordination uses `chat_close_store` / `chat_reopen_store` to manage database locks +- The main app suspends its chat controller when entering background, allowing NSE to access the database +- NSE is short-lived (~30 seconds per notification) and releases its lock quickly + +### App State Communication + +The `appStateGroupDefault` in `GroupDefaults` communicates app state between main app and NSE: +- `.active` -- main app is in foreground +- `.suspended` -- main app is in background +- `.stopped` -- main app is terminated + +The NSE checks this flag to determine whether to process notifications (it avoids processing if the main app is active). + +--- + +## Source Files + +| File | Path | +|------|------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | +| Database management UI | [`Shared/Views/Database/DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) | +| Encryption settings UI | [`Shared/Views/Database/DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) | +| C FFI (migration, file ops) | `SimpleXChat/SimpleX.h` | +| Haskell store root | `../../src/Simplex/Chat/Store/` | +| Haskell migrations | `../../src/Simplex/Chat/Store/SQLite/Migrations/` | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md new file mode 100644 index 0000000000..9593419b87 --- /dev/null +++ b/apps/ios/spec/impact.md @@ -0,0 +1,114 @@ +# SimpleX Chat iOS -- Impact Graph + +> Source file → product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Derived from [CODE.md](../CODE.md) Document Map and [product/concepts.md](../product/concepts.md). + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Push Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | + +--- + +## 1. Swift Source Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | +| Shared/SimpleXApp.swift | PC1 through PC30 | High | App entry point — initialization affects everything | +| Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | +| Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11 | High | Message composition — send path for all messages | +| Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | +| Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | +| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| Shared/Views/NewChat/NewChatView.swift | PC12 | High | New connection creation — onramp for all contacts | +| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | +| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | +| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle | +| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management | +| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI | +| Shared/Views/UserSettings/NetworkAndServers/ | PC25 | High | Server configuration — affects connectivity | +| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management | +| Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state | +| Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | +| Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | +| Shared/Views/Migration/ | PC26 | High | Device migration — data portability | +| Shared/Model/ChatModel.swift | PC1 through PC30 | High | Central state — all features depend on it | +| Shared/Model/SimpleXAPI.swift | PC1 through PC30 | High | FFI bridge — all commands flow through here | +| Shared/Model/AppAPITypes.swift | PC1 through PC30 | High | Command/response types — all API communication | +| Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | +| Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | +| Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | +| SimpleXChat/ChatTypes.swift | PC1 through PC30 | High | Core data types — all features use them | +| SimpleXChat/APITypes.swift | PC1 through PC30 | High | API result types and error handling | +| SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | +| SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | +| SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions | +| SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display | + +--- + +## 2. Haskell Core Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| src/Simplex/Chat/Controller.hs | PC1 through PC30 | High | Command processor — all API commands | +| src/Simplex/Chat/Types.hs | PC1 through PC30 | High | Core data types shared across all features | +| src/Simplex/Chat/Core.hs | PC1 through PC30 | High | Chat engine lifecycle | +| src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| src/Simplex/Chat/Call.hs | PC17 | Medium | Call signaling types | +| src/Simplex/Chat/Files.hs | PC10 | Medium | File transfer orchestration | +| src/Simplex/Chat/Store/Messages.hs | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| src/Simplex/Chat/Store/Groups.hs | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| src/Simplex/Chat/Store/Direct.hs | PC2, PC12, PC13 | High | Contact persistence | +| src/Simplex/Chat/Store/Files.hs | PC10 | Medium | File transfer persistence | +| src/Simplex/Chat/Store/Profiles.hs | PC19, PC21 | Medium | User profile persistence | +| src/Simplex/Chat/Store/Connections.hs | PC2, PC12 | High | Connection persistence and entity resolution | +| src/Simplex/Chat/Archive.hs | PC26 | Medium | Database export/import for migration | +| src/Simplex/Chat/ProfileGenerator.hs | PC20 | Low | Random profile generation for incognito | +| src/Simplex/Chat/Remote.hs | PC27 | Medium | Remote desktop protocol handler | +| src/Simplex/Chat/Remote/Types.hs | PC27 | Low | Remote desktop data types | +| src/Simplex/Chat/Types/UITheme.hs | PC24 | Low | Theme data types for UI customization | +| src/Simplex/Chat/Types/Preferences.hs | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| src/Simplex/Chat/Types/Shared.hs | PC3, PC16 | Medium | Shared types including GroupMemberRole | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md new file mode 100644 index 0000000000..6a1d89f6a3 --- /dev/null +++ b/apps/ios/spec/services/calls.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- WebRTC Calling Service + +> Technical specification for the calling system: CallController, WebRTCClient, CallKit integration, and signaling via SMP. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Notifications](notifications.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`CallController.swift`](../../Shared/Views/Call/CallController.swift) | [`WebRTCClient.swift`](../../Shared/Views/Call/WebRTCClient.swift) | [`ActiveCallView.swift`](../../Shared/Views/Call/ActiveCallView.swift) | [`CallTypes.swift`](../../SimpleXChat/CallTypes.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [CallController](#2-callcontroller) +3. [WebRTCClient](#3-webrtcclient) +4. [Call Flow via SMP](#4-call-flow-via-smp) +5. [CallKit Integration](#5-callkit-integration) +6. [CallKit-Free Mode](#6-callkit-free-mode) +7. [Audio Routing](#7-audio-routing) +8. [Key Types](#8-key-types) +9. [ActiveCallView](#9-activecallview) + +--- + +## 1. Overview + +SimpleX Chat provides end-to-end encrypted audio and video calls using WebRTC. The unique aspect is that all call signaling (SDP offers/answers, ICE candidates) is transmitted through the same encrypted SMP messaging channels used for chat, eliminating the need for a separate signaling server. + +``` +Caller SMP Relay Callee + │ │ │ + ├─ apiSendCallInvitation ──────→│──── push/event ──────→│ + │ │ │ + │ │←── apiSendCallOffer ──┤ + │←── ChatEvent.callOffer ───────│ │ + │ │ │ + ├─ apiSendCallAnswer ──────────→│──── callAnswer ──────→│ + │ │ │ + │←── callExtraInfo (ICE) ───────│←── apiSendCallExtraInfo│ + ├─ apiSendCallExtraInfo ───────→│──── callExtraInfo ───→│ + │ │ │ + │◄══════════ WebRTC P2P Media Stream ═══════════════════►│ + │ │ │ + ├─ apiEndCall ─────────────────→│──── callEnded ───────→│ +``` + +--- + +## [2. CallController](../../Shared/Views/Call/CallController.swift#L19-L449) + +**File**: `Shared/Views/Call/CallController.swift` + +Central call coordinator that bridges SimpleX call protocol with iOS CallKit (or non-CallKit fallback). + +### [Class Definition](../../Shared/Views/Call/CallController.swift#L19-L48) + +```swift +class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { + static let shared = CallController() + static let isInChina = SKStorefront().countryCode == "CHN" + static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + + private let provider: CXProvider // CallKit provider + private let controller: CXCallController // CallKit controller + private let callManager: CallManager // Internal call state + private let registry: PKPushRegistry // VoIP push registration + + @Published var activeCallInvitation: RcvCallInvitation? + var shouldSuspendChat: Bool = false + var fulfillOnConnect: CXAnswerCallAction? = nil +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`reportNewIncomingCall()`](../../Shared/Views/Call/CallController.swift#L287) | Reports incoming call to CallKit for native UI | L287 | +| [`reportOutgoingCall()`](../../Shared/Views/Call/CallController.swift#L328) | Reports outgoing call to CallKit | L328 | +| [`provider(_:perform: CXAnswerCallAction)`](../../Shared/Views/Call/CallController.swift#L66) | Handles user answering via CallKit UI | L66 | +| [`provider(_:perform: CXEndCallAction)`](../../Shared/Views/Call/CallController.swift#L96) | Handles user ending via CallKit UI | L96 | +| [`provider(_:perform: CXStartCallAction)`](../../Shared/Views/Call/CallController.swift#L55) | Handles outgoing call start | L55 | +| [`pushRegistry(_:didReceiveIncomingPushWith:)`](../../Shared/Views/Call/CallController.swift#L202) | Handles VoIP push tokens | L202 | +| [`hasActiveCalls()`](../../Shared/Views/Call/CallController.swift#L435) | Checks if any calls are active | L435 | + +### Call Manager (internal) + +`CallManager` tracks call state internally: +- Maps call UUIDs to `Call` objects +- Handles call state transitions +- Coordinates between CallKit actions and SimpleX API calls + +--- + +## [3. WebRTCClient](../../Shared/Views/Call/WebRTCClient.swift#L13-L676) + +**File**: `Shared/Views/Call/WebRTCClient.swift` (~49KB) + +Manages the WebRTC peer connection, media streams, and data channels. + +### Responsibilities + +- Creates and configures `RTCPeerConnection` +- Manages local audio/video capture (`RTCCameraVideoCapturer`, `RTCAudioTrack`) +- Handles SDP offer/answer creation and application +- Processes ICE candidates +- Manages media stream encryption + +### Key Operations + +| Operation | Description | Line | +|-----------|-------------|------| +| [`initializeCall`](../../Shared/Views/Call/WebRTCClient.swift#L93) | Sets up peer connection, tracks, encryption | L93 | +| [`createPeerConnection`](../../Shared/Views/Call/WebRTCClient.swift#L139) | Creates and configures RTCPeerConnection | L139 | +| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | +| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | +| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | +| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | +| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | +| [`endCall`](../../Shared/Views/Call/WebRTCClient.swift#L645) | Tears down connection and tracks | L645 | +| [`setupEncryptionForLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L503) | Sets up frame encryption for local media tracks | L503 | + +### [Additional Encryption](../../Shared/Views/Call/WebRTCClient.swift#L513-L546) + +Beyond WebRTC's built-in SRTP encryption, SimpleX adds an extra encryption layer: +- A shared key from the E2E SMP channel is used +- Applied via `chat_encrypt_media` / `chat_decrypt_media` C FFI functions +- Each media frame is encrypted/decrypted with this additional key +- Provides defense-in-depth even if SRTP is compromised + +--- + +## 4. Call Flow via SMP + +All call signaling travels through the same encrypted SMP message channels used for chat. No separate signaling server is needed. + +### Outgoing Call (Caller Side) + +``` +1. User initiates call + └── apiSendCallInvitation(contact:, callType:) + └── Sends CallInvitation via SMP to contact + +2. Callee accepts, sends SDP offer + └── ChatEvent.callOffer received + └── WebRTCClient creates answer + └── apiSendCallAnswer(contact:, answer:) + +3. ICE candidates exchanged + └── ChatEvent.callExtraInfo received → WebRTCClient.addIceCandidate() + └── WebRTCClient generates candidates → apiSendCallExtraInfo(contact:, extraInfo:) + +4. P2P connection established + └── Media streams flowing + +5. End call + └── apiEndCall(contact:) +``` + +### Incoming Call (Callee Side) + +``` +1. ChatEvent.callInvitation received (or push notification) + └── CallController reports to CallKit (or shows in-app notification) + +2. User accepts + └── WebRTCClient creates SDP offer (callee creates offer in SimpleX protocol) + └── apiSendCallOffer(contact:, callOffer:) + +3. Caller sends answer + └── ChatEvent.callAnswer received + └── WebRTCClient.setRemoteDescription(answer) + +4. ICE candidates exchanged (same as above) + +5. P2P connection established +``` + +### API Commands + +| Command | Direction | Purpose | +|---------|-----------|---------| +| `apiSendCallInvitation(contact:, callType:)` | Caller -> Callee | Initiate call | +| `apiRejectCall(contact:)` | Callee -> Caller | Reject call | +| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | +| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiEndCall(contact:)` | Either | End call | +| `apiGetCallInvitations` | -- | Get pending invitations | +| `apiCallStatus(contact:, callStatus:)` | -- | Report status change | + +--- + +## [5. CallKit Integration](../../Shared/Views/Call/CallController.swift#L24-L155) + +CallKit provides the native iOS incoming call experience (lock screen UI, call history, system call handling). + +### [CXProvider Configuration](../../Shared/Views/Call/CallController.swift#L24-L37) + +```swift +let configuration = CXProviderConfiguration() +configuration.supportsVideo = true +configuration.supportedHandleTypes = [.generic] +configuration.includesCallsInRecents = UserDefaults.standard.bool( + forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS +) +configuration.maximumCallGroups = 1 +configuration.maximumCallsPerCallGroup = 1 +configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() +``` + +### [VoIP Push (PKPushRegistry)](../../Shared/Views/Call/CallController.swift#L207-L284) + +CallKit requires VoIP push for incoming calls on locked device: +- `PKPushRegistry` registers for `.voIP` push type +- VoIP push token is separate from regular APNs token +- When VoIP push received, **must** report an incoming call to CallKit within the callback (iOS requirement) + +### CallKit Actions + +| CXAction | Handler | Description | Line | +|----------|---------|-------------|------| +| `CXStartCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L55) | User starts outgoing call | L55 | +| `CXAnswerCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L66) | User answers incoming call from CallKit UI | L66 | +| `CXEndCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L96) | User ends call from CallKit UI | L96 | +| `CXSetMutedCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L112) | User mutes from CallKit UI | L112 | + +### [Lock Screen Answer](../../Shared/Views/Call/CallController.swift#L66-L94) + +When answering from the lock screen: +1. `CXAnswerCallAction` fires +2. CallController waits for chat to be ready ([`waitUntilChatStarted(timeoutMs: 30_000)`](../../Shared/Views/Call/CallController.swift#L183)) +3. WebRTC connection established +4. `fulfillOnConnect` action is fulfilled only when WebRTC reaches connected state (required for audio to work on lock screen) + +--- + +## [6. CallKit-Free Mode](../../Shared/Views/Call/CallController.swift#L21-L22) + +In regions where CallKit is unavailable (e.g., China, determined by `SKStorefront.countryCode == "CHN"`), the app falls back to in-app notifications: + +```swift +static let isInChina = SKStorefront().countryCode == "CHN" +static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } +``` + +### Non-CallKit Behavior +- Incoming calls shown as in-app banners (via `CallController.activeCallInvitation`) +- No lock screen call UI +- No system call integration +- User can also manually disable CallKit via settings (`callKitEnabledGroupDefault`) + +--- + +## [7. Audio Routing](../../Shared/Views/Call/WebRTCClient.swift#L907-L1005) + +### [AVAudioSession Management](../../Shared/Views/Call/WebRTCClient.swift#L907-L950) + +Audio routing is managed through `AVAudioSession`: +- **Receiver**: Default for audio-only calls (ear speaker) +- **Speaker**: For video calls or when user toggles speaker +- **Bluetooth**: Detected and used when available +- **Headphones**: Detected and used when connected + +### Route Change Handling + +The `WebRTCClient` observes `AVAudioSession.routeChangeNotification` to handle: +- Bluetooth device connection/disconnection +- Headphone plug/unplug +- Speaker/receiver toggle + +--- + +## [8. Key Types](../../SimpleXChat/CallTypes.swift#L1-L115) + +### [RcvCallInvitation](../../SimpleXChat/CallTypes.swift#L45-L71) + +```swift +struct RcvCallInvitation { + var user: User + var contact: Contact + var callType: CallType + var sharedKey: String? // Optional E2E encryption key + var callUUID: String? + var callTs: Date +} +``` + +### [CallType](../../SimpleXChat/CallTypes.swift#L74-L82) + +```swift +struct CallType { + var media: CallMediaType // .audio or .video + var capabilities: CallCapabilities +} + +enum CallMediaType: String { + case audio + case video +} +``` + +### [WebRTCCallOffer](../../SimpleXChat/CallTypes.swift#L14-L22) / [WebRTCSession](../../SimpleXChat/CallTypes.swift#L25-L33) + +```swift +struct WebRTCCallOffer { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession { + var rtcSession: String // SDP string + var rtcIceCandidates: String // ICE candidates JSON +} +``` + +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) + +```swift +struct WebRTCExtraInfo { + var rtcIceCandidates: String // Additional ICE candidates +} +``` + +### Call (Active Call State) + +Stored in `ChatModel.activeCall`: +- Contact reference +- Call UUID +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Media type +- WebRTCClient reference + +--- + +## [9. ActiveCallView](../../Shared/Views/Call/ActiveCallView.swift#L16-L285) + +**File**: `Shared/Views/Call/ActiveCallView.swift` + +Full-screen call UI when `ChatModel.showCallView == true`: + +### UI Elements +- Remote video (full screen background) +- Local video (PiP corner, draggable) +- Contact name and call duration +- Control buttons: mute, camera toggle, speaker toggle, camera flip, end call +- Minimize button (collapses to banner) + +### [ActiveCallOverlay](../../Shared/Views/Call/ActiveCallView.swift#L288-L522) + +| Control | Method | Line | +|---------|--------|------| +| Audio call info | [`audioCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L357) | L357 | +| Video call info | [`videoCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L377) | L377 | +| End call | [`endCallButton`](../../Shared/Views/Call/ActiveCallView.swift#L407) | L407 | +| Mute toggle | [`toggleMicButton`](../../Shared/Views/Call/ActiveCallView.swift#L418) | L418 | +| Audio device | [`audioDeviceButton`](../../Shared/Views/Call/ActiveCallView.swift#L428) | L428 | +| Speaker toggle | [`toggleSpeakerButton`](../../Shared/Views/Call/ActiveCallView.swift#L452) | L452 | +| Camera toggle | [`toggleCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L464) | L464 | +| Flip camera | [`flipCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L475) | L475 | + +### PiP (Picture-in-Picture) + +When `ChatModel.activeCallViewIsCollapsed == true`: +- Call view collapses to a small floating overlay +- User can return to full-screen by tapping the banner +- Navigation continues normally underneath + +--- + +## Source Files + +| File | Path | Lines | +|------|------|-------| +| [Call controller](../../Shared/Views/Call/CallController.swift) | `Shared/Views/Call/CallController.swift` | 449 | +| [WebRTC client](../../Shared/Views/Call/WebRTCClient.swift) | `Shared/Views/Call/WebRTCClient.swift` | 1139 | +| [Active call UI](../../Shared/Views/Call/ActiveCallView.swift) | `Shared/Views/Call/ActiveCallView.swift` | 528 | +| WebRTC helpers | `Shared/Views/Call/WebRTC.swift` | | +| [Call types (Swift)](../../SimpleXChat/CallTypes.swift) | `SimpleXChat/CallTypes.swift` | 115 | +| Call types (Haskell) | `../../src/Simplex/Chat/Call.hs` | | diff --git a/apps/ios/spec/services/files.md b/apps/ios/spec/services/files.md new file mode 100644 index 0000000000..7e1f8a2ad1 --- /dev/null +++ b/apps/ios/spec/services/files.md @@ -0,0 +1,368 @@ +# SimpleX Chat iOS -- File Transfer Service + +> Technical specification for file transfer: inline/XFTP protocols, auto-receive thresholds, CryptoFile encryption, and file constants. +> +> Related specs: [Compose Module](../client/compose.md) | [Chat View](../client/chat-view.md) | [API Reference](../api.md) | [Database](../database.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | [`ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | [`AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Transfer Methods](#2-transfer-methods) +3. [Auto-Receive Thresholds](#3-auto-receive-thresholds) +4. [File Size Constants](#4-file-size-constants) +5. [Image Handling](#5-image-handling) +6. [Voice Messages](#6-voice-messages) +7. [CryptoFile -- At-Rest Encryption](#7-cryptofile) +8. [File Storage Paths](#8-file-storage-paths) +9. [File Lifecycle](#9-file-lifecycle) +10. [API Commands](#10-api-commands) + +--- + +## 1. Overview + +SimpleX Chat supports two file transfer methods depending on file size: + +``` +File ≤ 255KB (inline) +├── Base64 encoded directly in SMP message +├── Single message delivery +└── No extra server infrastructure needed + +File > 255KB up to 1GB (XFTP) +├── Encrypted and chunked +├── Uploaded to XFTP relay servers +├── Recipient downloads chunks from relays +└── Files auto-deleted from relays after download or expiry +``` + +All files are end-to-end encrypted. The XFTP protocol adds a second encryption layer on top of the SMP channel encryption. + +--- + +## 2. Transfer Methods + +### Inline Transfer + +- Files up to [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB) are base64-encoded and embedded directly in the SMP message body +- No additional protocol or server needed +- Delivered with the same reliability guarantees as regular messages +- Used primarily for compressed images + +### XFTP Transfer + +For files exceeding the inline threshold (up to [`MAX_FILE_SIZE_XFTP`](../../SimpleXChat/FileUtils.swift#L30) = 1GB): + +1. **Sender side**: + - File is AES-encrypted with a random key + - Encrypted file is split into chunks + - Chunks are uploaded to one or more XFTP relay servers + - File metadata (key, chunk locations) sent to recipient via SMP message + +2. **Recipient side**: + - Receives file metadata via SMP + - Downloads chunks from XFTP relays + - Reassembles and decrypts the file + +3. **Cleanup**: + - XFTP relays delete chunks after download or after expiry period + - No persistent storage on relays + +### SMP Transfer (legacy) + +[`MAX_FILE_SIZE_SMP`](../../SimpleXChat/FileUtils.swift#L34) (8MB) exists as a constant for larger inline transfers through SMP, used in specific scenarios. + +--- + +## 3. Auto-Receive Thresholds + +Files below certain size thresholds are automatically accepted and downloaded without user confirmation: + +| Media Type | Auto-Receive Threshold | Constant | Line | +|------------|----------------------|----------|------| +| Images | 510 KB | [`MAX_IMAGE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L21) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| Voice messages | 510 KB | [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| Video | 1023 KB | [`MAX_VIDEO_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L27) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| Other files | Not auto-received | Requires manual acceptance | -- | + +### Behavior + +- When a message with a file attachment arrives: + 1. Check if file size is below the auto-receive threshold for its type + 2. If below: automatically call [`setFileToReceive(fileId:, userApprovedRelays:, encrypted:)`](../../Shared/Model/AppAPITypes.swift#L168) followed by download + 3. If above: show download button in chat item, wait for user action + 4. User manually triggers download via [`receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:)`](../../Shared/Model/AppAPITypes.swift#L167) + +### Relay Approval + +`userApprovedRelays` parameter: when the file is hosted on relays not in the user's configured server list, the user is asked for confirmation before connecting to unknown relays. + +--- + +## [4. File Size Constants](../../SimpleXChat/FileUtils.swift#L18) + +Defined in [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift): + +| Constant | Value | Line | +|----------|-------|------| +| `MAX_IMAGE_SIZE` | 261,120 (255 KB) | [L18](../../SimpleXChat/FileUtils.swift#L18) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 (1023 KB) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 (1 GB) | [L30](../../SimpleXChat/FileUtils.swift#L30) | +| `MAX_FILE_SIZE_LOCAL` | Int64.max (no limit) | [L32](../../SimpleXChat/FileUtils.swift#L32) | +| `MAX_FILE_SIZE_SMP` | 8,000,000 (~7.6 MB) | [L34](../../SimpleXChat/FileUtils.swift#L34) | +| `MAX_VOICE_MESSAGE_LENGTH` | 300 s (5 min) | [L36](../../SimpleXChat/FileUtils.swift#L36) | + +```swift +// Image compression target for inline transfer +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB + +// Auto-receive thresholds +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB + +// Transfer method limits +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit (local notes) + +// Voice message constraints +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes (300 seconds) +``` + +--- + +## 5. Image Handling + +### Compression Pipeline + +1. User selects image (camera or photo library) +2. Image is compressed to fit within [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB): + - Progressive JPEG compression with decreasing quality + - Resize if dimensions are too large +3. Compressed image is base64-encoded into the message content +4. For larger images that cannot compress to 255KB: sent via XFTP + +### Display + +- `CIImageView` renders images in chat bubbles with aspect-fit sizing +- Tapping opens `FullScreenMediaView` with zoom/pan/share capabilities +- Thumbnail is displayed immediately; full-size loaded on demand for XFTP images + +### Animated Images + +- GIFs are handled by `AnimatedImageView` +- Displayed inline with animation support + +--- + +## 6. Voice Messages + +### Recording + +1. `ComposeVoiceView` manages the recording UI +2. `AudioRecPlay` handles `AVAudioRecorder` lifecycle +3. Recorded in compressed audio format +4. Maximum duration: [`MAX_VOICE_MESSAGE_LENGTH`](../../SimpleXChat/FileUtils.swift#L36) = 300 seconds (5 minutes) +5. Waveform data extracted for visualization + +### Transfer + +- Voice files up to [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) (510KB) are auto-received +- Larger voice files follow standard file transfer flow +- Voice messages include waveform metadata for UI rendering + +### Playback + +- `CIVoiceView` / `FramedCIVoiceView` render voice messages +- Shows waveform visualization and play/pause control +- `ChatModel.stopPreviousRecPlay` ensures only one audio source plays at a time +- Playback position and progress tracked + +--- + +## [7. CryptoFile -- At-Rest Encryption](../../SimpleXChat/ChatTypes.swift#L4241) + +When [`apiSetEncryptLocalFiles(enable: true)`](../../Shared/Model/SimpleXAPI.swift#L384) is configured, files stored on the device are AES-encrypted. + +### [`CryptoFile`](../../SimpleXChat/ChatTypes.swift#L4241) Type + +```swift +struct CryptoFile { + var filePath: String + var cryptoArgs: CryptoFileArgs? // nil = unencrypted +} + +struct CryptoFileArgs { + var fileKey: String // AES encryption key + var fileNonce: String // AES nonce/IV +} +``` + +> Defined in [`ChatTypes.swift` L4241](../../SimpleXChat/ChatTypes.swift#L4241) (`CryptoFile`) and [L4289](../../SimpleXChat/ChatTypes.swift#L4289) (`CryptoFileArgs`). + +### Encryption Operations (C FFI) + +Implemented in [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`writeCryptoFile`](../../SimpleXChat/CryptoFile.swift#L18) | Write encrypted file, returns `CryptoFileArgs` | [L18](../../SimpleXChat/CryptoFile.swift#L18) | +| [`readCryptoFile`](../../SimpleXChat/CryptoFile.swift#L31) | Read and decrypt file, returns `Data` | [L31](../../SimpleXChat/CryptoFile.swift#L31) | +| [`encryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L54) | Encrypt existing file to new path | [L54](../../SimpleXChat/CryptoFile.swift#L54) | +| [`decryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L66) | Decrypt file to new path | [L66](../../SimpleXChat/CryptoFile.swift#L66) | + +### Storage + +- Encrypted files stored alongside unencrypted files in `Documents/files/` +- The `CryptoFileArgs` (key + nonce) are stored in the Haskell database, not on the filesystem +- Toggle via privacy settings: [`apiSetEncryptLocalFiles(enable:)`](../../Shared/Model/SimpleXAPI.swift#L384) + +--- + +## [8. File Storage Paths](../../SimpleXChat/FileUtils.swift#L199) + +### Directory Structure + +| Function | Path | Line | +|----------|------|------| +| [`getAppFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L208) | `Documents/files/` | [L208](../../SimpleXChat/FileUtils.swift#L208) | +| [`getTempFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L199) | `Documents/temp_files/` | [L199](../../SimpleXChat/FileUtils.swift#L199) | +| [`getWallpaperDirectory()`](../../SimpleXChat/FileUtils.swift#L217) | `Documents/wallpapers/` | [L217](../../SimpleXChat/FileUtils.swift#L217) | +| [`getAppFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L212) | `Documents/files/{filename}` | [L212](../../SimpleXChat/FileUtils.swift#L212) | +| [`getWallpaperFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L221) | `Documents/wallpapers/{filename}` | [L221](../../SimpleXChat/FileUtils.swift#L221) | + +```swift +func getAppFilesDirectory() -> URL // Documents/files/ +func getTempFilesDirectory() -> URL // Documents/temp_files/ +func getWallpaperDirectory() -> URL // Documents/wallpapers/ +``` + +### Path Management + +- Downloaded files: `Documents/files/{filename}` +- Temporary files during transfer: `Documents/temp_files/` +- Wallpaper images: `Documents/wallpapers/` +- File paths are set via [`apiSetAppFilePaths(filesFolder:, tempFolder:, assetsFolder:)`](../../Shared/Model/SimpleXAPI.swift#L377) at startup + +--- + +## 9. File Lifecycle + +### Sending + +``` +1. User selects file/image/video in compose +2. ComposeView creates ComposedMessage with file reference +3. apiSendMessages() → Haskell core processes: + a. File ≤ inline threshold: base64 encode into message + b. File > inline threshold: start XFTP upload +4. Upload events: + - ChatEvent.sndFileStart + - ChatEvent.sndFileProgressXFTP (periodic progress) + - ChatEvent.sndFileCompleteXFTP (upload done) + - ChatEvent.sndFileError (on failure) +``` + +### Receiving + +``` +1. Message with file attachment arrives +2. Auto-receive check: + a. Below threshold: automatic download starts + b. Above threshold: user sees download button +3. User triggers download (or auto-triggered): + - receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:) +4. Download events: + - ChatEvent.rcvFileStart + - ChatEvent.rcvFileProgressXFTP (periodic progress) + - ChatEvent.rcvFileComplete (download done) + - ChatEvent.rcvFileError (on failure) + - ChatEvent.rcvFileSndCancelled (sender cancelled) +``` + +### Cancellation + +```swift +ChatCommand.cancelFile(fileId: Int64) +``` + +Cancels an in-progress upload or download. For XFTP transfers, also requests chunk deletion from relays. + +### Cleanup + +| Function | Purpose | Line | +|----------|---------|------| +| [`cleanupFile(_:)`](../../SimpleXChat/FileUtils.swift#L267) | Remove file associated with a chat item | [L267](../../SimpleXChat/FileUtils.swift#L267) | +| [`cleanupDirectFile(_:)`](../../SimpleXChat/FileUtils.swift#L260) | Remove file only for direct chats | [L260](../../SimpleXChat/FileUtils.swift#L260) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L243) | Delete file at URL | [L243](../../SimpleXChat/FileUtils.swift#L243) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L251) | Delete file by name | [L251](../../SimpleXChat/FileUtils.swift#L251) | +| [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) | Remove all app files (preserving databases) | [L108](../../SimpleXChat/FileUtils.swift#L108) | +| [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) | Remove everything | [L86](../../SimpleXChat/FileUtils.swift#L86) | + +- When a `ChatItem` is deleted, its associated file is deleted from disk +- When a timed message expires, its file is deleted +- `ChatModel.filesToDelete` queues files for deferred deletion +- [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) removes all files (preserving databases) +- [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) removes everything + +--- + +## [10. API Commands](../../Shared/Model/AppAPITypes.swift#L167) + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| [`receiveFile`](../../Shared/Model/AppAPITypes.swift#L167) | `fileId, userApprovedRelays, encrypted, inline` | Accept and start downloading a file | [L167](../../Shared/Model/AppAPITypes.swift#L167) | +| [`setFileToReceive`](../../Shared/Model/AppAPITypes.swift#L168) | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive (no immediate download) | [L168](../../Shared/Model/AppAPITypes.swift#L168) | +| [`cancelFile`](../../Shared/Model/AppAPITypes.swift#L169) | `fileId` | Cancel in-progress transfer | [L169](../../Shared/Model/AppAPITypes.swift#L169) | +| [`apiUploadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L179) | `userId, file: CryptoFile` | Upload file to XFTP without a chat context | [L179](../../Shared/Model/AppAPITypes.swift#L179) | +| [`apiDownloadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L180) | `userId, url, file: CryptoFile` | Download from XFTP URL | [L180](../../Shared/Model/AppAPITypes.swift#L180) | +| [`apiStandaloneFileInfo`](../../Shared/Model/AppAPITypes.swift#L181) | `url` | Get metadata for an XFTP URL | [L181](../../Shared/Model/AppAPITypes.swift#L181) | + +### File Transfer Events + +| Event | Description | Line | +|-------|-------------|------| +| [`rcvFileAccepted`](../../Shared/Model/AppAPITypes.swift#L1095) | Download request accepted | [L1095](../../Shared/Model/AppAPITypes.swift#L1095) | +| [`rcvFileStart`](../../Shared/Model/AppAPITypes.swift#L1097) | Download started | [L1097](../../Shared/Model/AppAPITypes.swift#L1097) | +| [`rcvFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1098) | Download progress (receivedSize, totalSize) | [L1098](../../Shared/Model/AppAPITypes.swift#L1098) | +| [`rcvFileComplete`](../../Shared/Model/AppAPITypes.swift#L1099) | Download complete | [L1099](../../Shared/Model/AppAPITypes.swift#L1099) | +| [`rcvFileSndCancelled`](../../Shared/Model/AppAPITypes.swift#L1101) | Sender cancelled the transfer | [L1101](../../Shared/Model/AppAPITypes.swift#L1101) | +| [`rcvFileError`](../../Shared/Model/AppAPITypes.swift#L1102) | Download failed | [L1102](../../Shared/Model/AppAPITypes.swift#L1102) | +| [`rcvFileWarning`](../../Shared/Model/AppAPITypes.swift#L1103) | Download warning (non-fatal) | [L1103](../../Shared/Model/AppAPITypes.swift#L1103) | +| [`sndFileStart`](../../Shared/Model/AppAPITypes.swift#L1105) | Upload started | [L1105](../../Shared/Model/AppAPITypes.swift#L1105) | +| [`sndFileComplete`](../../Shared/Model/AppAPITypes.swift#L1106) | Inline upload complete | [L1106](../../Shared/Model/AppAPITypes.swift#L1106) | +| [`sndFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1108) | XFTP upload progress (sentSize, totalSize) | [L1108](../../Shared/Model/AppAPITypes.swift#L1108) | +| [`sndFileCompleteXFTP`](../../Shared/Model/AppAPITypes.swift#L1110) | XFTP upload complete | [L1110](../../Shared/Model/AppAPITypes.swift#L1110) | +| [`sndFileRcvCancelled`](../../Shared/Model/AppAPITypes.swift#L1107) | Receiver cancelled | [L1107](../../Shared/Model/AppAPITypes.swift#L1107) | +| [`sndFileError`](../../Shared/Model/AppAPITypes.swift#L1112) | Upload failed | [L1112](../../Shared/Model/AppAPITypes.swift#L1112) | +| [`sndFileWarning`](../../Shared/Model/AppAPITypes.swift#L1113) | Upload warning (non-fatal) | [L1113](../../Shared/Model/AppAPITypes.swift#L1113) | + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | `MAX_IMAGE_SIZE`, `saveFile`, `removeFile`, `getMaxFileSize` | +| CryptoFile FFI operations | [`SimpleXChat/CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | `writeCryptoFile`, `readCryptoFile`, `encryptCryptoFile`, `decryptCryptoFile` | +| CryptoFile / CryptoFileArgs types | [`SimpleXChat/ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | `CryptoFile` (L4241), `CryptoFileArgs` (L4289) | +| API command definitions | [`Shared/Model/AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | `receiveFile`, `cancelFile`, `ChatEvent` file events | +| API implementations | [`Shared/Model/SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) | `receiveFile` (L1471), `cancelFile` (L1590) | +| File view (chat item) | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | | +| Image view (chat item) | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | | +| Video view (chat item) | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | | +| Voice view (chat item) | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | | +| Compose file preview | [`Shared/Views/Chat/ComposeMessage/ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | | +| Compose image preview | [`Shared/Views/Chat/ComposeMessage/ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | | +| Compose voice preview | [`Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | | +| C FFI (file encryption) | [`SimpleXChat/SimpleX.h`](../../SimpleXChat/SimpleX.h) | `chat_write_file`, `chat_read_file`, `chat_encrypt_file`, `chat_decrypt_file` | +| Haskell file logic | `../../src/Simplex/Chat/Files.hs` | -- | +| Haskell file store | `../../src/Simplex/Chat/Store/Files.hs` | -- | diff --git a/apps/ios/spec/services/notifications.md b/apps/ios/spec/services/notifications.md new file mode 100644 index 0000000000..1062833f9c --- /dev/null +++ b/apps/ios/spec/services/notifications.md @@ -0,0 +1,390 @@ +# SimpleX Chat iOS -- Push Notification Service + +> Technical specification for the notification system: NtfManager, Notification Service Extension (NSE), notification modes, and token lifecycle. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Navigation](../client/navigation.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`NtfManager.swift`](../../Shared/Model/NtfManager.swift) | [`BGManager.swift`](../../Shared/Model/BGManager.swift) | [`Notifications.swift`](../../SimpleXChat/Notifications.swift) | [`NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Notification Modes](#2-notification-modes) +3. [NtfManager](#3-ntfmanager) +4. [Notification Service Extension (NSE)](#4-notification-service-extension) +5. [Token Lifecycle](#5-token-lifecycle) +6. [Notification Categories & Actions](#6-notification-categories--actions) +7. [Badge Management](#7-badge-management) +8. [Background Tasks (BGManager)](#8-background-tasks) + +--- + +## 1. Overview + +SimpleX Chat uses a privacy-preserving notification architecture. Because messages are end-to-end encrypted and the notification server never sees message content, the app uses a Notification Service Extension (NSE) to decrypt push payloads on-device before displaying notifications. + +``` +APNs Push → NSE receives encrypted payload + → NSE starts Haskell core (own chat_ctrl) + → NSE decrypts message using stored keys + → NSE creates UNNotificationContent with decrypted preview + → iOS displays notification to user +``` + +The notification system has three modes of operation, allowing users to choose their privacy/convenience tradeoff. + +--- + +## 2. Notification Modes + +| Mode | Description | Mechanism | +|------|-------------|-----------| +| **Instant** | Real-time notifications via Apple Push | APNs push triggers NSE, which decrypts and displays | +| **Periodic** | Background fetch every ~20 minutes | `BGAppRefreshTask` wakes app, checks for new messages | +| **Off** | No notifications | User must open app to see messages | + +### Configuration + +Notification mode is set via: +```swift +ChatCommand.apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) +``` + +`NotificationsMode` enum: `.instant`, `.periodic`, `.off` + +The mode is stored in `ChatModel.notificationMode` and persisted in `GroupDefaults`. + +--- + +## 3. NtfManager + +**File**: [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) + +Central notification coordinator. Singleton: `NtfManager.shared`. + +### [Class Definition](../../Shared/Model/NtfManager.swift#L27) + +```swift +class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + static let shared = NtfManager() + public var navigatingToChat = false + private var granted = false + private var prevNtfTime: Dictionary = [:] +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`registerCategories()`](../../Shared/Model/NtfManager.swift#L156) | Registers notification action categories with iOS | [156](../../Shared/Model/NtfManager.swift#L156) | +| [`requestAuthorization()`](../../Shared/Model/NtfManager.swift#L215) | Requests notification permission from user | [215](../../Shared/Model/NtfManager.swift#L215) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Updates app icon badge | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`processNotificationResponse(_:)`](../../Shared/Model/NtfManager.swift#L54) | Handles user interaction with notification | [54](../../Shared/Model/NtfManager.swift#L54) | +| [`notifyContactRequest(_:)`](../../Shared/Model/NtfManager.swift#L239) | Shows contact request notification | [239](../../Shared/Model/NtfManager.swift#L239) | +| [`notifyCallInvitation(_:)`](../../Shared/Model/NtfManager.swift#L258) | Shows incoming call notification | [258](../../Shared/Model/NtfManager.swift#L258) | +| [`notifyMessageReceived(_:)`](../../Shared/Model/NtfManager.swift#L250) | Shows message received notification | [250](../../Shared/Model/NtfManager.swift#L250) | + +### [Notification Response Processing](../../Shared/Model/NtfManager.swift#L40) + +When user taps a notification: + +1. `userNotificationCenter(didReceive:)` delegate method fires +2. If app is active: calls `processNotificationResponse()` immediately +3. If app is inactive: stores in `ChatModel.notificationResponse` for later processing +4. [`processNotificationResponse()`](../../Shared/Model/NtfManager.swift#L54): + - Extracts `userId` from `userInfo` -- switches user if needed + - Extracts `chatId` -- navigates to the conversation + - Handles action identifiers (accept contact, accept/reject call) + +### [Rate Limiting](../../Shared/Model/NtfManager.swift#L144) + +`prevNtfTime` dictionary prevents notification flooding: +- Each chat has a timestamp of its last notification +- New notifications are suppressed if within `ntfTimeInterval` (1 second) of the previous one for the same chat + +--- + +## 4. Notification Service Extension (NSE) + +**File**: [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +### Architecture + +The NSE is a separate process that iOS launches when a push notification arrives. It has: +- Its own Haskell runtime instance (`chat_ctrl`) +- Shared database access (via app group container) +- ~30 second execution window per notification +- No access to main app's in-memory state + +### [Processing Flow](../../SimpleX NSE/NotificationService.swift#L300) + +``` +1. didReceive(request:, withContentHandler:) L300 + ├── 2. Initialize Haskell core (if not already running) + │ └── chat_migrate_init_key() with shared DB path L861 + ├── 3. Decode encrypted notification payload + │ └── apiGetNtfConns(nonce:, encNtfInfo:) L1123 + ├── 4. Fetch and decrypt messages + │ └── apiGetConnNtfMessages(connMsgReqs:) L1140 + ├── 5. Create notification content + │ ├── Contact name as title + │ ├── Decrypted message preview as body + │ └── Thread identifier for grouping + └── 6. Deliver to content handler +``` + +### NSE Commands + +The NSE uses a subset of the chat API: + +| Command | Purpose | Line | +|---------|---------|------| +| [`apiGetNtfConns(nonce:, encNtfInfo:)`](../../SimpleX NSE/NotificationService.swift#L1123) | Decrypt notification connection info | [1123](../../SimpleX NSE/NotificationService.swift#L1123) | +| [`apiGetConnNtfMessages(connMsgReqs:)`](../../SimpleX NSE/NotificationService.swift#L1140) | Fetch messages for notification connections | [1140](../../SimpleX NSE/NotificationService.swift#L1140) | + +### Database Coordination + +- NSE checks `appStateGroupDefault` before processing +- If main app is `.active`, NSE may skip processing (main app handles notifications directly) +- NSE uses `chat_close_store` / `chat_reopen_store` for safe concurrent access + +### [Preview Modes](../../SimpleXChat/APITypes.swift#L664) + +`NotificationPreviewMode` controls what the NSE shows: + +| Mode | Title | Body | +|------|-------|------| +| `.message` | Contact name | Message text | +| `.contact` | Contact name | "New message" | +| `.hidden` | "SimpleX" | "New message" | + +### Key Internal Types + +| Type | Purpose | Line | +|------|---------|------| +| [`NSENotificationData`](../../SimpleX NSE/NotificationService.swift#L27) | Enum of possible notification payloads | [27](../../SimpleX NSE/NotificationService.swift#L27) | +| [`NSEThreads`](../../SimpleX NSE/NotificationService.swift#L82) | Concurrency coordinator for multiple NSE instances | [82](../../SimpleX NSE/NotificationService.swift#L82) | +| [`NotificationEntity`](../../SimpleX NSE/NotificationService.swift#L245) | Per-connection processing state | [245](../../SimpleX NSE/NotificationService.swift#L245) | +| [`NotificationService`](../../SimpleX NSE/NotificationService.swift#L287) | Main NSE class (`UNNotificationServiceExtension`) | [287](../../SimpleX NSE/NotificationService.swift#L287) | +| [`NSEChatState`](../../SimpleX NSE/NotificationService.swift#L781) | Singleton managing NSE lifecycle state | [781](../../SimpleX NSE/NotificationService.swift#L781) | + +### Key Internal Functions + +| Function | Purpose | Line | +|----------|---------|------| +| [`startChat()`](../../SimpleX NSE/NotificationService.swift#L836) | Initializes Haskell core for NSE | [836](../../SimpleX NSE/NotificationService.swift#L836) | +| [`doStartChat()`](../../SimpleX NSE/NotificationService.swift#L861) | Performs actual chat initialization (migration, config) | [861](../../SimpleX NSE/NotificationService.swift#L861) | +| [`activateChat()`](../../SimpleX NSE/NotificationService.swift#L907) | Reactivates suspended chat controller | [907](../../SimpleX NSE/NotificationService.swift#L907) | +| [`suspendChat(_:)`](../../SimpleX NSE/NotificationService.swift#L921) | Suspends chat controller with timeout | [921](../../SimpleX NSE/NotificationService.swift#L921) | +| [`receiveMessages()`](../../SimpleX NSE/NotificationService.swift#L954) | Main message-receive loop | [954](../../SimpleX NSE/NotificationService.swift#L954) | +| [`receivedMsgNtf(_:)`](../../SimpleX NSE/NotificationService.swift#L1003) | Maps chat events to notification data | [1003](../../SimpleX NSE/NotificationService.swift#L1003) | +| [`receiveNtfMessages(_:)`](../../SimpleX NSE/NotificationService.swift#L403) | Orchestrates notification message fetch and delivery | [403](../../SimpleX NSE/NotificationService.swift#L403) | +| [`deliverBestAttemptNtf()`](../../SimpleX NSE/NotificationService.swift#L604) | Delivers the best available notification content | [604](../../SimpleX NSE/NotificationService.swift#L604) | +| [`didReceive(_:withContentHandler:)`](../../SimpleX%20NSE/NotificationService.swift#L300) | Main NSE entry point -- processes incoming notification | [300](../../SimpleX%20NSE/NotificationService.swift#L300) | + +--- + +## 5. Token Lifecycle + +### Registration Flow + +``` +1. App starts → AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken + └── ChatModel.deviceToken = token + +2. Token registration (when chat running and token available): + └── apiRegisterToken(token, notificationMode) + └── Response: ntfToken(token, status, ntfMode, ntfServer) + └── ChatModel.tokenStatus = status + +3. Token verification (if server requires): + └── apiVerifyToken(token, nonce, code) + └── ChatModel.tokenRegistered = true + +4. Token check (periodic): + └── apiCheckToken(token) + └── Updates ChatModel.tokenStatus +``` + +### Token States (NtfTknStatus) + +| Status | Description | +|--------|-------------| +| `.new` | Token just registered, not yet verified | +| `.registered` | Token registered with notification server | +| `.confirmed` | Token confirmed and ready | +| `.active` | Token actively receiving notifications | +| `.expired` | Token expired, needs re-registration | +| `.invalid` | Token invalid, needs new registration | +| `.invalidBad` | Token invalid due to bad data | +| `.invalidTopic` | Token invalid due to wrong topic | +| `.invalidExpired` | Token invalid because it expired | +| `.invalidUnregistered` | Token invalid, was unregistered | + +### Token Deletion + +```swift +ChatCommand.apiDeleteToken(token: DeviceToken) +``` + +Called when: +- User switches to `.off` notification mode +- User deletes their profile +- Token becomes invalid and needs replacement + +--- + +## 6. Notification Categories & Actions + +Registered in [`NtfManager.registerCategories()`](../../Shared/Model/NtfManager.swift#L156): + +### Contact Request Category + +```swift +// Category: "NTF_CAT_CONTACT_REQUEST" +// Actions: +// - "NTF_ACT_ACCEPT_CONTACT": Accept contact request +``` + +When user taps "Accept" on a contact request notification: +1. `processNotificationResponse()` detects `ntfActionAcceptContact` +2. Calls `apiAcceptContact(incognito: false, contactReqId:)` +3. Navigates to the new contact's chat + +### Call Invitation Category + +```swift +// Category: "NTF_CAT_CALL_INVITATION" +// Actions: +// - "NTF_ACT_ACCEPT_CALL": Accept incoming call +// - "NTF_ACT_REJECT_CALL": Reject incoming call +``` + +When user taps "Accept" / "Reject" on a call notification: +1. `processNotificationResponse()` detects the action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject)` +3. Call controller picks up the pending action + +### Message Category + +Standard tap-to-open behavior navigates to the chat. + +### Many Events Category + +Batch notification for multiple events -- navigates to the app without specific chat context. + +--- + +## 7. Badge Management + +The app icon badge shows the total unread message count: + +```swift +// Updated when: +// 1. App enters background: +NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) + +// 2. Messages are read: +// Badge is recalculated and updated + +// 3. NSE receives notification: +// NSE updates badge based on its count +``` + +`totalUnreadCountForAllUsers()` sums unread counts across all user profiles (not just the active user). + +### NSE Badge Handling + +| Method | Purpose | Line | +|--------|---------|------| +| [`setBadgeCount()`](../../SimpleX NSE/NotificationService.swift#L592) | Increments badge via `ntfBadgeCountGroupDefault` | [592](../../SimpleX NSE/NotificationService.swift#L592) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Sets badge on `UIApplication` | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`changeNtfBadgeCount(by:)`](../../Shared/Model/NtfManager.swift#L270) | Adjusts badge by delta | [270](../../Shared/Model/NtfManager.swift#L270) | + +--- + +## 8. Background Tasks + +**File**: [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) + +### [BGManager](../../Shared/Model/BGManager.swift#L30) + +```swift +class BGManager { + static let shared = BGManager() + func register() // Register BGAppRefreshTask handlers + func schedule() // Schedule next background refresh +} +``` + +| Method | Purpose | Line | +|--------|---------|------| +| [`register()`](../../Shared/Model/BGManager.swift#L38) | Registers `BGAppRefreshTask` handler with iOS | [38](../../Shared/Model/BGManager.swift#L38) | +| [`schedule()`](../../Shared/Model/BGManager.swift#L46) | Schedules next background refresh request | [46](../../Shared/Model/BGManager.swift#L46) | +| [`handleRefresh(_:)`](../../Shared/Model/BGManager.swift#L74) | Processes background refresh task | [74](../../Shared/Model/BGManager.swift#L74) | +| [`completionHandler(_:)`](../../Shared/Model/BGManager.swift#L95) | Creates completion callback with cleanup | [95](../../Shared/Model/BGManager.swift#L95) | +| [`receiveMessages(_:)`](../../Shared/Model/BGManager.swift#L112) | Activates chat and receives pending messages | [112](../../Shared/Model/BGManager.swift#L112) | + +### Background Refresh (Periodic Mode) + +When notification mode is `.periodic`: + +1. `BGManager.schedule()` is called when app enters background +2. iOS wakes the app in the background approximately every 20 minutes +3. `BGAppRefreshTask` handler: + - Activates the chat engine: `apiActivateChat(restoreChat: true)` + - Checks for new messages + - Creates local notifications for any new messages + - Suspends chat: `apiSuspendChat(timeoutMicroseconds:)` + - Schedules next refresh +4. Must complete within ~30 seconds or iOS terminates the task + +### Background Task Protection + +All API calls use `beginBGTask()` / `endBackgroundTask()` to request extra execution time: + +```swift +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { + var id: UIBackgroundTaskIdentifier! + // ... + id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask) + return endTask +} +``` + +Maximum task duration: `maxTaskDuration = 15` seconds. + +--- + +## Notification Content Builders + +**File**: [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) + +| Function | Purpose | Line | +|----------|---------|------| +| [`createContactRequestNtf()`](../../SimpleXChat/Notifications.swift#L27) | Builds notification for incoming contact request | [L27](../../SimpleXChat/Notifications.swift#L27) | +| [`createContactConnectedNtf()`](../../SimpleXChat/Notifications.swift#L46) | Builds notification for contact connected event | [L46](../../SimpleXChat/Notifications.swift#L46) | +| [`createMessageReceivedNtf()`](../../SimpleXChat/Notifications.swift#L66) | Builds notification for received message | [L66](../../SimpleXChat/Notifications.swift#L66) | +| [`createCallInvitationNtf()`](../../SimpleXChat/Notifications.swift#L86) | Builds notification for incoming call | [L86](../../SimpleXChat/Notifications.swift#L86) | +| [`createConnectionEventNtf()`](../../SimpleXChat/Notifications.swift#L102) | Builds notification for connection events | [L102](../../SimpleXChat/Notifications.swift#L102) | +| [`createErrorNtf()`](../../SimpleXChat/Notifications.swift#L134) | Builds notification for database/encryption errors | [L134](../../SimpleXChat/Notifications.swift#L134) | +| [`createAppStoppedNtf()`](../../SimpleXChat/Notifications.swift#L160) | Builds notification when app is stopped | [L160](../../SimpleXChat/Notifications.swift#L160) | +| [`createNotification()`](../../SimpleXChat/Notifications.swift#L175) | Generic notification builder (used by all above) | [L175](../../SimpleXChat/Notifications.swift#L175) | +| [`hideSecrets()`](../../SimpleXChat/Notifications.swift#L200) | Redacts secret-formatted text in previews | [L200](../../SimpleXChat/Notifications.swift#L200) | + +--- + +## Source Files + +| File | Path | +|------|------| +| Notification manager | [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) | +| Background manager | [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) | +| Notification types | [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) | +| NSE service | [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) | +| App delegate (token) | `Shared/AppDelegate.swift` | +| Notification settings UI | `Shared/Views/UserSettings/NotificationsView.swift` | diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md new file mode 100644 index 0000000000..321f3307f9 --- /dev/null +++ b/apps/ios/spec/services/theme.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- Theme Engine + +> Technical specification for the theming system: ThemeManager, default themes, customization layers, wallpapers, and YAML export. +> +> Related specs: [State Management](../state.md) | [Architecture](../architecture.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | [`ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | [`ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | [`Theme.swift`](../../Shared/Theme/Theme.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Customization Layers](#4-customization-layers) +5. [Color System](#5-color-system) +6. [Wallpapers](#6-wallpapers) +7. [Chat Bubble Styling](#7-chat-bubble-styling) +8. [Color Scheme Mode](#8-color-scheme-mode) +9. [YAML Export/Import](#9-yaml-exportimport) + +--- + +## 1. Overview + +The theme engine provides a layered customization system where themes can be overridden at multiple levels: global defaults, per-user, and per-chat. + +``` +Theme Resolution Order (most specific wins): +┌─────────────────────┐ +│ Per-chat override │ apiSetChatUIThemes(chatId:, themes:) +├─────────────────────┤ +│ Per-user override │ apiSetUserUIThemes(userId:, themes:) +├─────────────────────┤ +│ App settings theme │ themeOverridesDefault (UserDefaults) +├─────────────────────┤ +│ Base theme │ Light / Dark / SimpleX / Black +└─────────────────────┘ +``` + +The resolved theme is published as `AppTheme.shared` and consumed by all SwiftUI views via `@EnvironmentObject`. + +--- + +## 2. [ThemeManager](../../Shared/Theme/ThemeManager.swift) (L15) + +**File**: [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) + +Static utility class that resolves the current theme by merging all customization layers. + +### [ActiveTheme](../../Shared/Theme/ThemeManager.swift#L17) + +The resolved theme output: + +```swift +struct ActiveTheme: Equatable { + let name: String // Theme name (e.g., "light", "dark", "simplex", "black", "system") + let base: DefaultTheme // Base theme enum + let colors: Colors // Resolved color palette + let appColors: AppColors // App-specific colors (sent/received bubbles, etc.) + var wallpaper: AppWallpaper // Resolved wallpaper +} +``` + +### Key Static Methods + +| Method | Purpose | Line | +|--------|---------|------| +| [`applyTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L124) | Apply a theme by name, updates `AppTheme.shared` | [L124](../../Shared/Theme/ThemeManager.swift#L124) | +| [`currentColors(...)`](../../Shared/Theme/ThemeManager.swift#L64) | Resolve full theme from all layers | [L64](../../Shared/Theme/ThemeManager.swift#L64) | +| [`defaultActiveTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L48) | Get default theme override from app settings | [L48](../../Shared/Theme/ThemeManager.swift#L48) | +| [`currentThemeOverridesForExport(...)`](../../Shared/Theme/ThemeManager.swift#L105) | Get current overrides for YAML export | [L105](../../Shared/Theme/ThemeManager.swift#L105) | +| [`adjustWindowStyle()`](../../Shared/Theme/ThemeManager.swift#L136) | Adjust window style after theme change | [L136](../../Shared/Theme/ThemeManager.swift#L136) | +| [`changeDarkTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L166) | Change the dark theme variant | [L166](../../Shared/Theme/ThemeManager.swift#L166) | +| [`saveAndApplyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L173) | Save and apply a theme color override | [L173](../../Shared/Theme/ThemeManager.swift#L173) | +| [`applyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L186) | Apply a theme color to a binding | [L186](../../Shared/Theme/ThemeManager.swift#L186) | +| [`saveAndApplyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L191) | Save and apply a wallpaper change | [L191](../../Shared/Theme/ThemeManager.swift#L191) | +| [`copyFromSameThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L213) | Copy overrides from matching theme | [L213](../../Shared/Theme/ThemeManager.swift#L213) | +| [`applyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L256) | Apply wallpaper to a binding | [L256](../../Shared/Theme/ThemeManager.swift#L256) | +| [`saveAndApplyThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L267) | Save and apply full theme overrides | [L267](../../Shared/Theme/ThemeManager.swift#L267) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L288) | Reset all color overrides (CodableDefault) | [L288](../../Shared/Theme/ThemeManager.swift#L288) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L302) | Reset all color overrides (Binding) | [L302](../../Shared/Theme/ThemeManager.swift#L302) | +| [`removeTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L311) | Remove a saved theme by ID | [L311](../../Shared/Theme/ThemeManager.swift#L311) | + +### Theme Resolution Algorithm + +[`currentColors()`](../../Shared/Theme/ThemeManager.swift#L64) in `ThemeManager.swift`: + +1. Determine base theme from `currentThemeDefault`: + - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) + - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +3. Look up app settings theme override (`themeOverridesDefault`) +4. Look up per-user theme override (`User.uiThemes`) +5. Look up per-chat theme override (from ChatInfo) +6. Look up wallpaper preset colors (if wallpaper has preset color overrides) +7. Merge layers: base <- app override <- preset wallpaper colors <- per-user <- per-chat +8. Return `ActiveTheme` with resolved colors, app colors, and wallpaper + +--- + +## 3. Default Themes + +Four built-in themes with pre-defined color palettes: + +| Theme | Enum | Key Characteristics | +|-------|------|---------------------| +| **Light** | `DefaultTheme.LIGHT` | White background, standard colors | +| **Dark** | `DefaultTheme.DARK` | Dark gray background, light text | +| **SimpleX** | `DefaultTheme.SIMPLEX` | Brand purple accents, dark background | +| **Black** | `DefaultTheme.BLACK` | Pure black background (OLED), high contrast | + +### [DefaultTheme](../../SimpleXChat/Theme/ThemeTypes.swift#L13) Enum + +```swift +enum DefaultTheme { + case LIGHT + case DARK + case SIMPLEX + case BLACK + + static let SYSTEM_THEME_NAME = "SYSTEM" + + var themeName: String { ... } + var mode: DefaultThemeMode { ... } // .light or .dark +} +``` + +### Color Palettes + +Each base theme defines two palette types: +- [`Colors`](../../SimpleXChat/Theme/ThemeTypes.swift#L44): Standard UI colors (primary, background, surface, error, onBackground, onSurface) +- [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) + +Palette instances: +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) + +--- + +## 4. Customization Layers + +### Layer 1: App Settings Theme + +Stored in `themeOverridesDefault` (UserDefaults). Contains `[ThemeOverrides]` -- an array of theme overrides, one per base theme. + +#### [`ThemeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L385) + +```swift +struct ThemeOverrides: Codable { + var base: DefaultTheme + var colors: ThemeColors? // Color overrides + var wallpaper: ThemeWallpaper? // Wallpaper setting +} +``` + +### Layer 2: Per-User Theme + +Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in the Haskell database via `apiSetUserUIThemes(userId:, themes:)`. + +#### [`ThemeModeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L570) + +```swift +struct ThemeModeOverrides: Codable { + var light: ThemeModeOverride? + var dark: ThemeModeOverride? +} +``` + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) + +```swift +struct ThemeModeOverride: Codable { + var mode: DefaultThemeMode? + var colors: ThemeColors? + var wallpaper: ThemeWallpaper? + var type: WallpaperType? // Computed from wallpaper +} +``` + +### Layer 3: Per-Chat Theme + +Stored per-chat via `apiSetChatUIThemes(chatId:, themes:)`. Same `ThemeModeOverrides` structure. + +### Override Merging + +Colors are merged field-by-field: if a more-specific layer defines a color, it overrides; if nil, falls through to the next layer. + +--- + +## 5. Color System + +**File**: [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) + +### [ThemeColors](../../SimpleXChat/Theme/ThemeTypes.swift#L230) + +Overridable color definitions: + +```swift +struct ThemeColors: Codable { + var primary: String? // Primary brand color + var primaryVariant: String? // Primary variant + var secondary: String? // Secondary color + var secondaryVariant: String? // Secondary variant + var background: String? // Main background + var surface: String? // Card/surface background + var title: String? // Title text color + var primaryVariant2: String? // Additional variant + var sentMessage: String? // Sent message bubble + var sentQuote: String? // Sent quote background + var receivedMessage: String? // Received message bubble + var receivedQuote: String? // Received quote background +} +``` + +Colors are stored as hex strings (e.g., `"#FF6600"`) and converted to SwiftUI `Color` values at resolution time. + +### [Colors](../../SimpleXChat/Theme/ThemeTypes.swift#L44) (Resolved Palette) + +```swift +struct Colors { + var isLight: Bool + var primary: Color + var primaryVariant: Color + var secondary: Color + var secondaryVariant: Color + var background: Color + var surface: Color + var error: Color + var onBackground: Color + var onSurface: Color + // ... etc +} +``` + +### [AppColors](../../SimpleXChat/Theme/ThemeTypes.swift#L90) (Resolved App-Specific) + +```swift +struct AppColors { + var title: Color + var primaryVariant2: Color + var sentMessage: Color + var sentQuote: Color + var receivedMessage: Color + var receivedQuote: Color +} +``` + +--- + +## 6. Wallpapers + +**File**: [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) + +### [Preset Wallpapers](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L13) + +6 built-in wallpaper presets: + +| Preset | ID | Description | +|--------|-----|-------------| +| Cats | `cats` | Cat-themed pattern | +| Flowers | `flowers` | Floral pattern | +| Hearts | `hearts` | Heart pattern | +| Kids | `kids` | Children's pattern | +| School | `school` | School/notebook pattern (default) | +| Travel | `travel` | Travel-themed pattern | + +Each preset defines per-theme color tints (`PresetWallpaper.colors[DefaultTheme]`) that subtly adjust the color palette to complement the wallpaper. + +### Custom Wallpapers + +Users can set a custom image as wallpaper: +- Stored in `Documents/wallpapers/` directory +- Scaled and tiled to fill the chat background +- Custom wallpapers can be combined with color overrides + +### [WallpaperType](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L311) + +```swift +enum WallpaperType { + case preset(filename: String, scale: Float?) // Built-in wallpaper + case image(filename: String, scale: Float?) // Custom image + case empty // No wallpaper +} +``` + +### [AppWallpaper](../../SimpleXChat/Theme/ThemeTypes.swift#L142) (Resolved) + +```swift +struct AppWallpaper { + var background: Color? // Background color override + var tint: Color? // Tint/overlay color + var type: WallpaperType +} +``` + +--- + +## 7. Chat Bubble Styling + +Configurable bubble appearance properties: + +| Property | Description | Stored In | +|----------|-------------|-----------| +| `chatItemRoundness` | Corner radius of message bubbles | App settings | +| `chatItemTail` | Whether bubbles have a tail/arrow | App settings | +| Avatar corner radius | Roundness of profile avatars | App settings | + +These are configured in [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) ([L26](../../Shared/Views/UserSettings/AppearanceSettings.swift#L26)). + +--- + +## 8. Color Scheme Mode + +### System Follow + +When theme is set to `"system"` (DefaultTheme.SYSTEM_THEME_NAME): +- Light mode: uses `DefaultTheme.LIGHT` palette +- Dark mode: uses the configured dark theme (`systemDarkThemeDefault`), which can be Dark, SimpleX, or Black + +### Forced Mode + +Users can force light or dark mode regardless of system setting by selecting a specific theme other than "system". + +### Detection + +[`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95): + +```swift +var systemInDarkThemeCurrently: Bool { + return UITraitCollection.current.userInterfaceStyle == .dark +} +``` + +`ChatModel.currentUser` setter triggers [`ThemeManager.applyTheme()`](../../Shared/Theme/ThemeManager.swift#L124) to handle per-user theme overrides when switching users. + +--- + +## 9. YAML Export/Import + +Theme configurations can be exported as YAML for sharing: + +### Export + +[`ThemeManager.currentThemeOverridesForExport()`](../../Shared/Theme/ThemeManager.swift#L105) generates a `ThemeOverrides` representing the current resolved theme, which is then serialized to YAML using the Yams library. + +### Import + +YAML theme strings are parsed back into `ThemeOverrides` and applied as app settings theme overrides. + +Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`ImportExportThemeSection`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | UI section for import/export controls | [L603](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | +| [`ThemeImporter`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | ViewModifier for YAML file import | [L640](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | +| [`decodeYAML(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | Parse YAML string into Decodable type | [L1150](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | +| [`encodeThemeOverrides(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | Encode ThemeOverrides to YAML string | [L1160](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | + +### Toolbar Material + +[`ToolbarMaterial`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L319) controls the navigation bar appearance: +- Configurable opacity/material (translucent, opaque) +- Stored in app settings + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | +| Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | +| App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | +| Appearance settings UI | [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | `AppearanceSettings` (L26), `ToolbarMaterial` (L319), `ImportExportThemeSection` (L603) | +| Theme mode editor | `Shared/Views/Helpers/ThemeModeEditor.swift` | Theme mode selection UI | +| Haskell theme types | `../../src/Simplex/Chat/Types/UITheme.hs` | Server-side theme persistence | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md new file mode 100644 index 0000000000..68b5f3cbcc --- /dev/null +++ b/apps/ios/spec/state.md @@ -0,0 +1,463 @@ +# SimpleX Chat iOS -- State Management + +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1375) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5284) + +> Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. +> +> Related specs: [Architecture](architecture.md) | [API Reference](api.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel -- Primary App State](#2-chatmodel) +3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel) +4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel) +5. [Chat -- Single Conversation State](#5-chat) +6. [ChatInfo -- Conversation Metadata](#6-chatinfo) +7. [State Flow](#7-state-flow) +8. [Preference Storage](#8-preference-storage) + +--- + +## 1. Overview + +The app uses SwiftUI's `ObservableObject` pattern for reactive state management. The state hierarchy is: + +``` +ChatModel (singleton -- global app state) +├── currentUser: User? +├── users: [UserInfo] +├── chats: [Chat] (chat list) +├── chatId: String? (active chat ID) +├── im: ItemsModel.shared (primary chat items) +├── secondaryIM: ItemsModel? (secondary chat items, e.g. support scope) +├── activeCall: Call? +├── callInvitations: [ChatId: RcvCallInvitation] +├── deviceToken / savedToken / tokenStatus +├── notificationMode: NotificationsMode +├── onboardingStage: OnboardingStage? +├── migrationState: MigrationToState? +└── ... (50+ @Published properties) + +ItemsModel (singleton + secondary instances -- per-chat message state) +├── reversedChatItems: [ChatItem] (messages in reverse order) +├── chatState: ActiveChatState (pagination/split state) +├── isLoading / showLoadingProgress +└── preloadState: PreloadState + +Chat (per-conversation -- one per entry in chat list) +├── chatInfo: ChatInfo (type + metadata) +├── chatItems: [ChatItem] (preview items) +└── chatStats: ChatStats (unread counts) + +ChatTagsModel (singleton -- filter state) +├── userTags: [ChatTag] +├── activeFilter: ActiveFilter? +├── presetTags: [PresetTag: Int] +└── unreadTags: [Int64: Int] +``` + +--- + +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L337-L1260) + +**Class**: `final class ChatModel: ObservableObject` +**Singleton**: `ChatModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) + +### Key Published Properties + +#### App Lifecycle +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L331](../Shared/Model/ChatModel.swift#L338) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L340](../Shared/Model/ChatModel.swift#L347) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L341](../Shared/Model/ChatModel.swift#L348) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L342](../Shared/Model/ChatModel.swift#L349) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L343](../Shared/Model/ChatModel.swift#L350) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L344](../Shared/Model/ChatModel.swift#L351) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L345](../Shared/Model/ChatModel.swift#L352) | +| `migrationState` | `MigrationToState?` | Device migration state | [L390](../Shared/Model/ChatModel.swift#L398) | + +#### User State +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L334](../Shared/Model/ChatModel.swift#L341) | +| `users` | `[UserInfo]` | All user profiles | [L339](../Shared/Model/ChatModel.swift#L346) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L333](../Shared/Model/ChatModel.swift#L340) | + +#### Chat List +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chats` | `[Chat]` (private set) | All conversations for current user | [L351](../Shared/Model/ChatModel.swift#L358) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L352](../Shared/Model/ChatModel.swift#L359) | + +#### Active Chat +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatId` | `String?` | Currently open chat ID | [L354](../Shared/Model/ChatModel.swift#L361) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L355](../Shared/Model/ChatModel.swift#L362) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L356](../Shared/Model/ChatModel.swift#L363) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L357](../Shared/Model/ChatModel.swift#L364) | +| `chatToTop` | `String?` | Chat to scroll to top | [L358](../Shared/Model/ChatModel.swift#L365) | +| `groupMembers` | `[GMember]` | Members of active group | [L359](../Shared/Model/ChatModel.swift#L366) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L360](../Shared/Model/ChatModel.swift#L367) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L361](../Shared/Model/ChatModel.swift#L368) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L408](../Shared/Model/ChatModel.swift#L416) | + +#### Authentication +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L348](../Shared/Model/ChatModel.swift#L355) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L349](../Shared/Model/ChatModel.swift#L356) | + +#### Notifications +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L369](../Shared/Model/ChatModel.swift#L376) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L370](../Shared/Model/ChatModel.swift#L377) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L371](../Shared/Model/ChatModel.swift#L378) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L373](../Shared/Model/ChatModel.swift#L380) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L374](../Shared/Model/ChatModel.swift#L381) | +| `notificationServer` | `String?` | Notification server URL | [L375](../Shared/Model/ChatModel.swift#L382) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L376](../Shared/Model/ChatModel.swift#L383) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L346](../Shared/Model/ChatModel.swift#L353) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L378](../Shared/Model/ChatModel.swift#L385) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L379](../Shared/Model/ChatModel.swift#L386) | + +#### Calls +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L381](../Shared/Model/ChatModel.swift#L388) | +| `activeCall` | `Call?` | Currently active call | [L382](../Shared/Model/ChatModel.swift#L389) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L383](../Shared/Model/ChatModel.swift#L390) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L384](../Shared/Model/ChatModel.swift#L391) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L385](../Shared/Model/ChatModel.swift#L392) | + +#### Remote Desktop +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L387](../Shared/Model/ChatModel.swift#L395) | + +#### Misc +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L365](../Shared/Model/ChatModel.swift#L372) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L366](../Shared/Model/ChatModel.swift#L373) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L367](../Shared/Model/ChatModel.swift#L374) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L368](../Shared/Model/ChatModel.swift#L375) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L389](../Shared/Model/ChatModel.swift#L397) | +| `draft` | `ComposeState?` | Saved compose draft | [L393](../Shared/Model/ChatModel.swift#L401) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L394](../Shared/Model/ChatModel.swift#L402) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L395](../Shared/Model/ChatModel.swift#L403) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L397](../Shared/Model/ChatModel.swift#L405) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L392](../Shared/Model/ChatModel.swift#L400) | + +### Non-Published Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L399](../Shared/Model/ChatModel.swift#L407) | +| `filesToDelete` | `Set` | Files queued for deletion | [L401](../Shared/Model/ChatModel.swift#L409) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L405](../Shared/Model/ChatModel.swift#L413) | + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `getUser(_ userId:)` | Find user by ID | [L427](../Shared/Model/ChatModel.swift#L436) | +| `updateUser(_ user:)` | Update user in list and current | [L437](../Shared/Model/ChatModel.swift#L447) | +| `removeUser(_ user:)` | Remove user from list | [L446](../Shared/Model/ChatModel.swift#L457) | +| `getChat(_ id:)` | Find chat by ID | [L456](../Shared/Model/ChatModel.swift#L468) | +| `addChat(_ chat:)` | Add chat to list | [L510](../Shared/Model/ChatModel.swift#L523) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L523](../Shared/Model/ChatModel.swift#L537) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L574](../Shared/Model/ChatModel.swift#L589) | +| `removeChat(_ id:)` | Remove chat from list | [L1180](../Shared/Model/ChatModel.swift#L1198) | +| `popChat(_ id:, _ ts:)` | Move chat to top of list | [L1157](../Shared/Model/ChatModel.swift#L1174) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1058](../Shared/Model/ChatModel.swift#L1074) | + +--- + +## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L74-L174) + +**Class**: `class ItemsModel: ObservableObject` +**Primary singleton**: `ItemsModel.shared` +**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (e.g., group member support chat) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L74) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L78](../Shared/Model/ChatModel.swift#L80) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L81](../Shared/Model/ChatModel.swift#L83) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L85](../Shared/Model/ChatModel.swift#L87) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L89](../Shared/Model/ChatModel.swift#L91) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L90](../Shared/Model/ChatModel.swift#L92) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L75](../Shared/Model/ChatModel.swift#L77) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L74](../Shared/Model/ChatModel.swift#L76) | + +### Computed Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L95](../Shared/Model/ChatModel.swift#L97) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L154](../Shared/Model/ChatModel.swift#L159) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L162](../Shared/Model/ChatModel.swift#L167) | + +### Throttling + +`ItemsModel` uses a custom publisher throttle (0.2 seconds) to batch rapid updates to `reversedChatItems` and prevent excessive SwiftUI re-renders: + +```swift +publisher + .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) + .sink { self.objectWillChange.send() } + .store(in: &bag) +``` + +Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throttling for immediate UI response. + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L113](../Shared/Model/ChatModel.swift#L117) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L138](../Shared/Model/ChatModel.swift#L143) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L107](../Shared/Model/ChatModel.swift#L110) | + +### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) + +Used for secondary chat views (e.g., group member support scope, content type filter): + +```swift +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) +} +``` + +--- + +## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L189-L291) + +**Class**: `class ChatTagsModel: ObservableObject` +**Singleton**: `ChatTagsModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L189) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userTags` | `[ChatTag]` | User-defined tags | [L186](../Shared/Model/ChatModel.swift#L192) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L187](../Shared/Model/ChatModel.swift#L193) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L188](../Shared/Model/ChatModel.swift#L194) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L189](../Shared/Model/ChatModel.swift#L195) | + +### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) + +```swift +enum ActiveFilter { + case presetTag(PresetTag) // .favorites, .contacts, .groups, .business, .groupReports + case userTag(ChatTag) // User-defined tag + case unread // Unread conversations +} +``` + +--- + +## 5. [Chat](../Shared/Model/ChatModel.swift#L1311-L1323) + +**Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1271) + +Represents a single conversation in the chat list. Each `Chat` is an independent observable object. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1253](../Shared/Model/ChatModel.swift#L1272) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1254](../Shared/Model/ChatModel.swift#L1273) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1255](../Shared/Model/ChatModel.swift#L1274) | +| `created` | `Date` | Creation timestamp | [L1256](../Shared/Model/ChatModel.swift#L1275) | + +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1877-L1899) + +```swift +struct ChatStats: Decodable, Hashable { + var unreadCount: Int = 0 + var unreadMentions: Int = 0 + var reportsCount: Int = 0 + var minUnreadItemId: Int64 = 0 + var unreadChat: Bool = false +} +``` + +### Computed Properties + +| Property | Description | Line | +|----------|-------------|------| +| `id` | Chat ID from `chatInfo.id` | [L1287](../Shared/Model/ChatModel.swift#L1306) | +| `viewId` | Unique view identity including creation time | [L1289](../Shared/Model/ChatModel.swift#L1308) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1279](../Shared/Model/ChatModel.swift#L1298) | +| `supportUnreadCount` | Unread count for group support scope | [L1291](../Shared/Model/ChatModel.swift#L1310) | + +--- + +## 6. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1372-L1852) + +**Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1372) + +Represents the type and metadata of a conversation: + +```swift +public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { + case direct(contact: Contact) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case local(noteFolder: NoteFolder) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + case invalidJSON(json: Data?) +} +``` + +### Cases + +| Case | Associated Value | Description | +|------|-----------------|-------------| +| `.direct` | `Contact` | One-to-one conversation | +| `.group` | `GroupInfo, GroupChatScopeInfo?` | Group conversation (optional scope for member support threads) | +| `.local` | `NoteFolder` | Local notes (self-chat) | +| `.contactRequest` | `UserContactRequest` | Incoming contact request | +| `.contactConnection` | `PendingContactConnection` | Pending connection | +| `.invalidJSON` | `Data?` | Undecodable chat data | + +### Key Computed Properties on ChatInfo + +| Property | Type | Description | +|----------|------|-------------| +| `chatType` | `ChatType` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `id` | `ChatId` | Prefixed ID (e.g., `"@1"` for direct, `"#5"` for group) | +| `displayName` | `String` | Contact/group name | +| `image` | `String?` | Profile image (base64) | +| `chatSettings` | `ChatSettings?` | Notification/favorite settings | +| `chatTags` | `[Int64]?` | Assigned tag IDs | + +--- + +## 7. State Flow + +### App Start +``` +SimpleXApp.init() + → haskell_init() + → initChatAndMigrate() + → chat_migrate_init_key() -- creates/opens DB + → startChat(mainApp: true) -- starts core + → apiGetChats(userId) -- populates ChatModel.chats + → UI renders ChatListView +``` + +### Opening a Chat +``` +User taps chat in ChatListView + → ItemsModel.loadOpenChat(chatId) + → 250ms delay for navigation animation + → ChatModel.chatId = chatId + → loadChat(chatId:, im:) + → apiGetChat(chatId, pagination: .last(count: 50)) + → ItemsModel.reversedChatItems = [ChatItem] + → ChatView renders messages +``` + +### Receiving a Message (Event) +``` +Haskell core generates ChatEvent.newChatItems + → Event loop calls chat_recv_msg_wait + → Decoded as ChatEvent.newChatItems(user, chatItems) + → ChatModel updates: + 1. Insert new Chat items into ChatModel.chats (preview) + 2. If chat is open: insert into ItemsModel.reversedChatItems + 3. Update ChatStats (unread counts) + 4. Update ChatTagsModel (tag unread counts) + → SwiftUI re-renders affected views via @Published observation +``` + +### Sending a Message +``` +User taps send in ComposeView + → apiSendMessages(type, id, scope, live, ttl, composedMessages) + → Haskell processes, returns ChatResponse1.newChatItems + → ChatModel.chats updated with new preview + → ItemsModel.reversedChatItems gets new item + → ChatView scrolls to bottom, shows sent message +``` + +--- + +## 8. Preference Storage + +### UserDefaults (via @AppStorage) + +App-level UI settings stored in `UserDefaults.standard`: + +| Key Constant | Type | Description | +|--------------|------|-------------| +| `DEFAULT_PERFORM_LA` | `Bool` | Enable local authentication | +| `DEFAULT_PRIVACY_PROTECT_SCREEN` | `Bool` | Hide screen in app switcher | +| `DEFAULT_SHOW_LA_NOTICE` | `Bool` | Show LA setup notice | +| `DEFAULT_NOTIFICATION_ALERT_SHOWN` | `Bool` | Notification permission alert shown | +| `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` | `Bool` | Show CallKit calls in recents | + +### GroupDefaults + +Settings shared between main app and extensions (NSE, SE) via app group `UserDefaults`: + +| Key | Description | +|-----|-------------| +| `appStateGroupDefault` | Current app state (.active/.suspended/.stopped) | +| `dbContainerGroupDefault` | Database container location (.group/.documents) | +| `ntfPreviewModeGroupDefault` | Notification preview mode | +| `storeDBPassphraseGroupDefault` | Whether to store DB passphrase | +| `callKitEnabledGroupDefault` | Whether CallKit is enabled | +| `onboardingStageDefault` | Current onboarding stage | +| `currentThemeDefault` | Current theme name | +| `systemDarkThemeDefault` | Dark mode theme name | +| `themeOverridesDefault` | Custom theme overrides | +| `currentThemeIdsDefault` | Active theme override IDs | + +### Keychain (KeyChain wrapper) + +Sensitive data stored in iOS Keychain: + +| Key | Description | +|-----|-------------| +| `kcDatabasePassword` | SQLite database encryption key | +| `kcAppPassword` | App lock password | +| `kcSelfDestructPassword` | Self-destruct trigger password | + +### Haskell DB (via apiSaveSettings / apiGetSettings) + +Chat-level preferences stored in the SQLite database (managed by Haskell core): + +- Per-contact preferences (timed messages, voice, calls, etc.) +- Per-group preferences +- Per-user notification settings +- Network configuration +- Server lists + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatModel, ItemsModel, Chat, ChatTagsModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | +| ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) | +| ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) | +| Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) |