Merge branch 'master' into chat-relays

This commit is contained in:
spaced4ndy
2026-02-19 15:29:18 +04:00
149 changed files with 9834 additions and 94 deletions
+219
View File
@@ -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 46.
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 |
+1
View File
@@ -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
+10
View File
@@ -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 {
+6
View File
@@ -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)
+6
View File
@@ -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")
+20
View File
@@ -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]
+10
View File
@@ -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))
}
+19
View File
@@ -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<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
return r
}
// Spec: spec/api.md#chatSendCmdSync
@inline(__always)
func chatSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R {
let res: APIResult<R> = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
return try apiResult(res)
}
// Spec: spec/api.md#chatApiSendCmdSync
func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult<R> {
if log {
logger.debug("chatSendCmd \(cmd.cmdType)")
@@ -112,12 +116,14 @@ func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = tru
return resp
}
// Spec: spec/api.md#chatSendCmd
@inline(__always)
func chatSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R {
let res: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
return try apiResult(res)
}
// Spec: spec/api.md#chatApiSendCmdWithRetry
func chatApiSendCmdWithRetry<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue<Bool>? = nil, retryNum: Int32 = 0) async -> APIResult<R>? {
let r: APIResult<R> = 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<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult<R> {
await withCheckedContinuation { cont in
@@ -226,6 +233,7 @@ func apiResult<R: ChatAPIResult>(_ res: APIResult<R>) throws -> R {
}
}
// Spec: spec/api.md#chatRecvMsg
func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult<ChatEvent>? {
await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) { () -> APIResult<ChatEvent>? 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<Void, Never>?
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)")
+3
View File
@@ -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
+3
View File
@@ -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
}
+13
View File
@@ -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 })
@@ -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) {
@@ -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
}
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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?
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -38,6 +38,7 @@ extension EnvironmentValues {
}
}
// Spec: spec/client/chat-view.md#ChatItemView
struct ChatItemView: View {
@ObservedObject var chat: Chat
@ObservedObject var im: ItemsModel
+19
View File
@@ -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<ChatItem>) -> Array<ChatItem> {
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
@@ -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))
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -5,6 +5,7 @@
// Created by Evgeny on 10/04/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
// Spec: spec/architecture.md
import SwiftUI
@@ -5,6 +5,7 @@
// Created by Evgeny on 11/04/2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
// Spec: spec/architecture.md
import SwiftUI
@@ -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
@@ -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
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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<T: Decodable>(_ string: String) -> T? {
do {
return try YAMLDecoder().decode(T.self, from: string)
@@ -1150,6 +1156,7 @@ private func decodeYAML<T: Decodable>(_ 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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
+1
View File
@@ -110,6 +110,7 @@ public func resetChatCtrl() {
migrationResult = nil
}
// Spec: spec/api.md#sendSimpleXCmd
@inline(__always)
public func sendSimpleXCmd<R: ChatAPIResult>(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult<R> {
if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) {
+6
View File
@@ -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<R>: Decodable where R: Decodable, R: ChatAPIResult {
case result(R)
case error(ChatError)
@@ -59,6 +61,7 @@ public enum APIResult<R>: 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<R: ChatAPIResult>(_ d: Data) -> APIResult<R> {
// print("decodeAPIResult \(String(describing: R.self))")
do {
@@ -691,6 +695,7 @@ private func encodeCJSON<T: Encodable>(_ 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)
+6
View File
@@ -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
+5
View File
@@ -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<String, URL>()
}
// Spec: spec/services/files.md#CryptoFileArgs
public struct CryptoFileArgs: Codable, Hashable {
public var fileKey: String
public var fileNonce: String
+5
View File
@@ -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)!
+19
View File
@@ -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
+10
View File
@@ -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 = ""
@@ -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 {
@@ -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()
+258
View File
@@ -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`
+83
View File
@@ -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`
+179
View File
@@ -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
+159
View File
@@ -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<Bool>) 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
+209
View File
@@ -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
+216
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More