Files
simplex-chat/apps/ios/Shared/Model/SimpleXAPI.swift
T
sh 10a814694c core, ui: support SimpleX names (#7045)
* deps: bump simplexmq for ConnectTarget

* chat: migration adds simplex_name to contacts, groups, connections

Nullable TEXT column on all three tables, with partial indexes on
contacts(user_id, simplex_name) and groups(user_id, simplex_name)
for the upcoming connectPlanName lookup. connections.simplex_name
is the transient carrier from APIConnect -> XInfo handler, where
the value is copied to contacts.simplex_name at delayed create.
No reads or writes yet - column threading lands in subsequent commits.

* tests: provide namesConfig = Nothing in smpServerCfg

Follow-up to the simplexmq pin bump (ee0a45e9). The new
namesConfig :: Maybe NamesConfig field on ServerConfig (introduced
in simplexmq's namespace branch) needs to appear in the test
fixture's record literal, otherwise the test suite fails to compile
under -Werror. Disabled by default (Nothing).

* chat: thread simplexName through Contact/GroupInfo/Connection records

Adds `simplexName :: Maybe SimplexNameInfo` to the three records and
extends every SELECT path that reconstructs them to read the new
column. Decoded via eitherToMaybe . strDecode . encodeUtf8 (the
codebase's established pattern for Maybe Text -> typed-decode fields),
extracted as decodeSimplexName helper since the chain appears in
toContact / toContact' / toGroupInfo / toConnection. INSERT paths
still write Nothing - the write-side wiring lands in the next commit
(Task 7).

* deps: bump simplexmq for SimplexNameInfo FromField/ToField

* chat: cross-reference groupInfoQueryFields and getGroupAndMember_

Two helpers redundantly maintain the same g.* column list. A future
g.* addition must be applied to both sites; the cross-reference
comments flag this for maintainers. A proper refactor (reusing
groupInfoQueryFields from Connections.hs's inline SELECT) is out of
scope for this branch.

* chat: persist simplexName on prepare and connect-via-plan write paths

createPreparedContact/createPreparedGroup gain a Maybe SimplexNameInfo
parameter that they write to contacts.simplex_name / groups.simplex_name
directly. createConnection_ writes to connections.simplex_name as a
transient carrier for the connect-via-plan path. The XInfo handler
in Library/Subscriber.hs reads the connection's simplexName and passes
it to createDirectContact so the final contact row captures the name.

All current callers pass Nothing; the actual flow lights up when
APIConnectPlan accepts ConnectTarget and connectPlanName threads the
name through (later commits in this branch).

Uses the upstream ToField SimplexNameInfo (simplexmq 0b334b66) for
writes; reads continue to go via the soft-degradation helper.

* chat: APIConnectPlan accepts ConnectTarget; connectPlanName looks up by name

APIConnectPlan/Connect flip from Maybe AConnectionLink to Maybe ConnectTarget.
connectPlan dispatches CTLink -> connectPlanLink (the prior body, renamed)
and CTName -> connectPlanName (new) which looks the name up against
contacts.simplex_name and groups.simplex_name via the new
getContactBySimplexName / getGroupInfoBySimplexName store helpers.

The hit path returns the contact's / group's stored conn link from
preparedContact / preparedGroup; missing prepared state or unknown
names return CEInvalidConnReq. RSLV on-chain resolution is out of
scope for this branch -- known-name lookup is enough for conversation
display, search, and external-link share.

connLinkP_ parser is unchanged: APIConnect's preparedLink_ stays
ACreatedConnLink-shaped, and the Connect / APIConnectPlan parsers
already use inline strP for ConnectTarget without going through the
helper.

Directory.Service call sites updated to wrap their AConnectionLink in
CTLink when invoking APIConnectPlan.

* chat: surface simplexName in conversation view + JSON output

viewConnectionPlan now shows the simplex name beneath known
contacts/groups (ILPKnown, CAPKnown, CAPContactViaAddress, GLPKnown
active/prepared/deleted). The TH-derived Contact / GroupInfo JSON
instances automatically expose `simplexName` (omitted when Nothing
per defaultJSON's omitNothingFields), which unblocks client-side
display and search.

No JSON test added: there is no Contact-level JSON test module in
this codebase; coverage is already provided by defaultJSON's
omitNothingFields = True behaviour.

Server-side substring search has no existing pattern in this
codebase; client renderers index simplexName themselves once it
appears in the JSON shape.

External-link share (preferring simplex:/name… form over the raw
link when the contact has a simplexName) lands in the next commit.

* chat: share/copy output prefers simplexName when present

When a contact or group has a simplex_name stored, the share-link
render path emits the canonical simplex:/name... URI (via strEncode)
instead of the underlying connection link. Falls back to the existing
link rendering when simplexName is Nothing.

Final commit of the ConnectTarget plumbing chain: end-to-end users
can now (a) connect via @alice.simplex / #group.simplex with the
agent layer carrying the name, (b) see the simplex name on the
contact/group records and in viewConnectionPlan, (c) share the
contact using the namespace-canonical form rather than the raw URI.

* deps: bump simplexmq for boundedNonSpace + drop unused FromField

* chat: simplex_name partial indexes are UNIQUE

A simplex name is a stable, per-user identity (one name → one contact
or group). Without a unique constraint, a later writer that populates
the column twice for the same name would silently produce two matching
rows, and getContactBySimplexName/getGroupInfoBySimplexName would
return whichever the planner picks first.

Promote the partial indexes added in M20260603 to UNIQUE before any
caller wires the writes. Predicate (WHERE simplex_name IS NOT NULL)
already scopes the constraint to rows that opted in.

* chat: regen Postgres schema dump for UNIQUE simplex_name indexes

Follow-up to f71c579c. The SchemaDump test runs against SQLite only;
the parallel PostgresSchemaDump suite gates on -fclient_postgres and
a running localhost PG instance, which this environment doesn't have.
Updated the Postgres schema dump by hand to mirror the migration
change (two lines: CREATE INDEX → CREATE UNIQUE INDEX).

* chat: CESimplexNameNotFound for name lookup misses

connectPlanName now distinguishes "name not found" from "connection
link is invalid". CEInvalidConnReq's message ("Connection link is
invalid, possibly it was created in a previous version") was
misleading when a user typed @alice.simplex against a database that
simply has no contact by that name.

The two "missing prepared link" cases stay on CEInvalidConnReq —
the lookup found a row but the stored link is unusable, which is
closer to the existing semantics. The two truly-missing cases
(no contact found / no group found) move to CESimplexNameNotFound,
which also surfaces the name back to the client for a precise UX.

* chat: exclude soft-deleted contacts from idx_contacts_simplex_name

The lookup `getContactBySimplexName` (Store/Direct.hs:781) filters
`AND deleted = 0`, but the index predicate `WHERE simplex_name IS NOT
NULL` covered tombstoned rows too. Forward-compat trap: once writers
land a non-Nothing simplex_name, soft-deleting a contact would block
re-claiming its name (UNIQUE conflict) even though the lookup reports
the slot as free.

Tighten the partial-index predicate to also require deleted = 0 so the
constraint scope matches the live-lookup scope. Groups have no soft-
delete column, so their index stays as-is.

* chat: connectPlanName dispatches on nameType first

Previously the function always probed contacts.simplex_name first and
fell through to groups for NTPublicGroup misses. But the discriminator
(`@`/`#`) is embedded in the stored bytes via strEncode, so an
`#group.simplex` lookup can never match a contact row. Reorder to
case on nameType up front, saving one DB query and one withFastStore
transaction acquire on the group path.

* chat: CESimplexNameUnprepared for found-but-no-link cases + member-removed filter

connectPlanName previously threw CEInvalidConnReq when a name lookup
hit a contact / group row whose preparedContact / preparedGroup was
NULL. The error message ("Connection link is invalid, possibly it was
created in a previous version") was wrong: the name resolved fine,
the device just has no link material to reconnect via (typical for a
contact created via the XInfo handler rather than the prepare path).
Introduce CESimplexNameUnprepared SimplexNameInfo for this case.

Also mirror the link-based path's gPlan (Commands.hs:4133) for groups
whose membership state is GSMemRemoved — return CESimplexNameNotFound
rather than GLPKnown for a removed-member group, since GLPKnown for
removed members would be inconsistent with how /_connect plan over a
short link handles the same situation.

* chat: fix stale CRITICAL comment in saveConnInfo

Comment claimed SEDBException is re-thrown as CRITICAL but only
SEDBBusyError is (via the `critical` helper at Subscriber.hs:136
and the showCritical branch at :1695). Updated to describe the actual
behaviour.

* chat: fix misleading decodeSimplexName docstring

The comment described "@alice.simplex" as the column's surface form,
but ToField SimplexNameInfo writes the canonical strEncode output
("simplex:/name@alice.simplex"). Aligns the docstring with what the
column actually holds.

* chat: drop redundant T.unpack in CESimplexName* error rendering

plain has a Text instance (verified by sibling simplexNameLine at
View.hs:2176 which uses it directly). The T.unpack in the new error
renderings was inconsistent with the same-feature helper. Cosmetic
cleanup.

* deps: bump simplexmq for resolveSimplexName

* chat: simplexName field on Profile, GroupProfile, LocalProfile

Adds a Maybe SimplexNameInfo field to the wire-level Profile and
GroupProfile (and their DB sibling LocalProfile). JSON instances are
TH-derived with omitNothingFields = True, so the new optional field is
auto-handled and old peers / old JSON without the key decode as Nothing.

Existing record-construction sites are set to simplexName = Nothing as
a placeholder. Outgoing dissemination (userProfileDirect /
userProfileInGroup) and incoming persistence wire-up land in follow-up
commits. redactedMemberProfile passes the field through, matching how
peerType is preserved.

* chat: load LocalProfile.simplexName from simplex_name column

Populate the embedded LocalProfile.simplexName field for the user's own
profile and for peer Contact / GroupInfo from the existing simplex_name
columns on contacts and groups. Previously every DB read set this field
to Nothing (Task 1 placeholder), so downstream consumers that work off
LocalProfile / GroupProfile (e.g., userProfileDirect / userProfileInGroup
that build outgoing XInfo / XGrpInfo via fromLocalProfile) saw Nothing
unconditionally.

Scope is limited to the rows where the simplex_name column actually
exists: contacts (per-user) and groups (per-user). Sites that only read
contact_profiles / group_profiles (toContactRequest, toContactProfile,
toGroupProfile, rowToLocalProfile) remain Nothing; Task 3 adds the
profile-table columns and wires them up.

* chat: test outgoing Profile carries simplexName from User profile

userProfileDirect, userProfileInGroup' and redactedMemberProfile already
pass simplexName through via fromLocalProfile (Task 1) once the embedded
LocalProfile field is populated (previous commit). Lock that behavior in
with focused unit tests:

- userProfileDirect with Just simplexName -> wire Profile.simplexName Just
- userProfileDirect with Nothing -> wire Nothing
- userProfileDirect with an incognito Profile overlay -> wire Nothing
  (incognito identity must not leak the user's registered name)
- userProfileInGroup' pass-through
- redactedMemberProfile pass-through (forwarded member profiles)

* chat: clarify groups.simplex_name stopgap comment

Drop the asymmetric toContact comment (the mirror is now obvious post-Task-1)
and rewrite the toGroupInfo stopgap comment to reflect the actual semantics:
groups.simplex_name is per-user locally-known, mirrored into groupProfile as a
stopgap until group_profiles.simplex_name lands.

* chat: add simplex_name to contact_profiles and group_profiles

Adds nullable simplex_name TEXT column and partial UNIQUE
(user_id, simplex_name) index to both contact_profiles and group_profiles
tables. Distinct from contacts.simplex_name / groups.simplex_name
(M20260603), which carry the user's locally-known label set by the
prepare-via-name path; the new columns will carry the peer's broadcast
claim received via XInfo / XGrpInfo (wired up in following commits).

* chat: persist peer-claimed simplexName from incoming profiles

Write paths:
- updateContactProfile_' / updateGroupProfile_ now set the new
  contact_profiles.simplex_name / group_profiles.simplex_name columns
  from Profile.simplexName / GroupProfile.simplexName respectively.
- createContact_ INSERT writes Profile.simplexName to the new
  contact_profiles column (separate from the existing simplexName arg,
  which still writes contacts.simplex_name — the user's locally-known
  label).

Read paths (closing Task 2's deferred sites):
- toContact splits simplex_name reads: Contact.simplexName from
  contacts.simplex_name (existing); LocalProfile.simplexName from
  contact_profiles.simplex_name (new column).
- toGroupInfo similarly splits: GroupInfo.simplexName from
  groups.simplex_name; groupProfile.simplexName from
  group_profiles.simplex_name.
- ProfileRow / rowToLocalProfile, toContactRequest, getUserContactProfiles,
  toGroupProfile, getProfileById, groupMemberQuery, getGroupAndMember_,
  saveRcvChatItem-related quotes — all extended to read p.simplex_name
  and decode it into LocalProfile.simplexName / GroupProfile.simplexName.

Conflict handling (Decision B):
- clearConflictingContactProfileSimplexName_ / *Group* helpers do an
  atomic UPDATE-with-RETURNING that NULLs simplex_name on any other
  row in the same user that would collide on the partial UNIQUE index,
  returning the displaced row's display_name.
- updateContactProfileWithConflict / updateGroupProfileWithConflict
  bundle clear+update in one transaction.
- processContactProfileUpdate / xGrpInfo invoke the *WithConflict
  variants and emit CEvtSimplexNameConflict when a displacement
  happened (with the claiming and displaced display names).

Adds ChatEvent CEvtSimplexNameConflict and SimplexNameConflictEntity
(SNCEContact / SNCEGroup) with JSON instances and View.hs rendering.

* chat: fix review findings on simplex_name persistence

- updateUserProfile no longer writes contact_profiles.simplex_name on
  the user's own row (the column is reserved for peer claims; the user's
  broadcastable name lives on contacts.simplex_name via uct.simplex_name).
- updateMemberContactProfile_'/Reset_' now write simplex_name; new
  updateMemberProfileWithConflict / updateContactMemberProfileWithConflict
  variants run conflict-clear and return the displaced name, with
  processMemberProfileUpdate emitting CEvtSimplexNameConflict.
- createContact_ runs conflict-clear before INSERT to avoid UNIQUE
  constraint violations on first-write peer collisions, returning the
  displaced name; createPreparedContact / createDirectContact thread it
  through to APIPrepareContact and saveConnInfo XInfo for event emission.
- groups conflict-clear takes ProfileId directly (avoids the NOT IN (NULL)
  silent-noop edge case when groups.group_profile_id is ON DELETE SET NULL).
- Moves clearConflictingContactProfileSimplexName_ to Shared.hs so
  createContact_ can call it without inducing a circular import.

* chat: resolveOnUserServers iterates user SMP servers for RSLV

* chat: connectPlanName falls back to RSLV when local lookup misses

* chat: RSLV-resolved NameRecord dispatched through prepared row

dispatchResolvedRecord now picks the first nrContactLinks (NTContact) or
nrChannelLinks (NTPublicGroup) entry from the resolved record, decodes it
as AConnShortLink, fetches the short-link data, and eagerly calls
createPreparedContact / createPreparedGroup with the simplex_name set.

Returning CPContactAddress (CAPKnown ct) / CPGroupLink (GLPKnown g ...)
mirrors the local-store-hit branch of connectPlanName: hit and miss
converge on the same plan shape, so the connectWithPlan caller cannot
distinguish where the prepared row came from. Threading uses the
existing Maybe SimplexNameInfo parameter added in c6f26150 for the
local-prepare path -- no new write path or transient carrier.

Pure helper firstNameLink is extracted and exported so the link-picker
contract is testable without a DB / agent. ResolveNameTests gains five
cases covering the per-type selection, the first-link policy, and the
empty-list to CESimplexNameNotFound collapse.

* chat: regen query plans after simplex_name plumbing

* chat: register ConnectTarget + CEvtSimplexNameConflict in bot docs

Bot API docs generator failed with "Undefined type: ConnectTarget"
since f2394d121 (prior plan) flipped APIConnectPlan/Connect from
Maybe AConnectionLink to Maybe ConnectTarget without updating
bots/src/API/Docs/*. Also adds SimplexNameConflictEntity (new in
cd0de9659) and documents the CEvtSimplexNameConflict event for
peer-name displacement notifications. Regenerates the affected
markdown / TypeScript / Python artefacts.

* deps: bump simplexmq for NameRecord reshape; update consumers

simplexmq 5ee014dd reshaped NameRecord to align with the Python resolver
JSON: nrChannelLinks/nrContactLinks (lists of NameLink) became
nrSimplexChannel/nrSimplexContact (Maybe Text); nrDisplayName became
nrName; nrResolver was added; the NameLink wrapper type and nrIsTest/
nrExpiry/nrAdminAddress/nrAdminEmail fields were dropped.

Update dispatchResolvedRecord destructure and firstNameLink signature
to the new Maybe Text shape, and refresh the ResolveNameTests fixtures
and assertions accordingly.

* chat: resolveOnUserServers iterates only on transport errors

Privacy: every miss previously broadcast the candidate name to every
enabled SMP server. Now only NETWORK / TIMEOUT failures fall through
to the next server; definite resolver answers (NAME / AUTH / CMD
PROHIBITED / other ERR) stop iteration.

* chat: document why groups simplex_name index has no soft-delete filter

The contacts simplex_name index filters on (deleted = 0); the groups
index has no analogous filter because the groups table has no `deleted`
column. Groups are hard-deleted by deleteGroup, so the asymmetry is
intentional. The remaining "removed member, row retained" edge case is
flagged in the lookup comment for follow-up.

* chat: document connections.simplex_name as transient carrier

Audit flagged the column as "INSERTed but never UPDATEd". This is by
design per the prior plan's connect-via-plan flow: the column is a
transient carrier between connection-creation and contact-creation.
After the Contact row is created via XInfo handling, contacts.simplex_name
is the source of truth and the connections value is a historical snapshot.
Documents the intent so future readers don't reflag it.

* chat: extract surfaceSimplexNameConflict helper

Six call sites duplicated the same forM_ ((,) <$> claim <*> displaced)
shape emitting CEvtSimplexNameConflict. Extract to a single helper so
future call sites don't drift on whether to emit, and so the conflict
event shape change (post-Task-3 SimplexNameConflictEntity split into
SNCEContact / SNCEGroup) propagates through one site.

* chat: APIVerifySimplexName command + CEvtSimplexNameUnverified warning

Addresses the TOFU vulnerability where peer-claimed simplex_name was
accepted unverified. Adds:

- contacts.simplex_name_verified_at + groups.simplex_name_verified_at
  (M20260606_simplex_name_verified)
- APIVerifySimplexName ChatRef command: RSLV-resolves the claimed name
  and compares the resolved link to the peer's stored connection link;
  on match writes verified_at and emits CEvtSimplexNameVerified;
  on mismatch emits CEvtSimplexNameVerifyFailed
- CEvtSimplexNameUnverified passive warning emitted on incoming XInfo /
  XGrpInfo when a name claim arrives without a current verification
- updateContactProfileWithConflict / updateGroupProfileWithConflict
  clear simplex_name_verified_at whenever the peer's claim transitions
  (any value change including Nothing<->Just): the prior verification
  was bound to the prior claim.

UI can surface the unverified indicator next to a contact / group's
name, and prompt the user to invoke the verify command. This shifts
the security model from "TOFU + last-writer-wins" to "TOFU + on-demand
RSLV verification".

* chat: register APIVerifySimplexName + verify events in bot docs

ebe90f716 added the verify command + events + SimplexNameVerifyFailReason
type without touching bots/src/API/Docs/. Mirrors commit 0d7ea8061 which
addressed the same gap for ConnectTarget. Regenerates the affected
markdown / TypeScript / Python artefacts.

* chat: bump simplexmq pin + document cross-table simplex_name discriminator

Pin bump 5ee014dd -> c9c2d19 picks up the 8 simplexmq commits since the
last bump (parseBare lowercase fix, forwarded-param cleanup, ServerTests
+ agent end-to-end tests, TldRegistries removal, SNRC ABI decoder,
NameRecord/NameOwner module extraction).

Adds a brief comment on clearConflictingContactProfileSimplexName_
explaining why the audit's flagged cross-table collision (between
contact_profiles.simplex_name and group_profiles.simplex_name) is
structurally impossible: SimplexNameInfo's strEncode prefixes contact
names with '@' and group names with '#', so the stored bytes never
overlap between the two tables.

Query-plan regen deferred (the test is non-deterministic in CI / dev
sandbox — see prior 6c990696c).

* deps: bump simplexmq for HTTP resolver; adapt NameRecord consumers

simplexmq 92b3d049 reshaped NameRecord text fields from Maybe Text to
Text (empty string sentinel). Adapt firstNameLink to take Text directly
and treat T.null as "absent". dispatchResolvedRecord destructure
unchanged; passes the text values straight through. apiVerifySimplexName
switches from Just/Nothing pattern to a T.null guard with the same UX.
Test fixtures updated.

* deps: bump simplexmq for multi-link NameRecord; adapt consumers

* core: treat RSLV CMD UNKNOWN as no name-resolution support

* core: fix contact-by-connection query missing simplex_name_verified_at

* deps: update sha256map for simplexmq f555e9af pin

* core: iterate past RSLV-unsupported name servers

* core: filter RSLV servers by operator enablement

* core: align resolver docs/tests with RSLV errors

* deps: bump simplexmq to df1aa24c

* refactor(names): agent resolution + one error type

Adopt the simplexmq names rework (PR #7045): name resolution is now
owned by the agent (resolveSimplexName picks a names-role server), so
the chat-side iteration is removed - delete ResolveError,
iterateResolvers, resolveOnUserServers, enabledSMPServersForUser and
resolveErrorToChatError.

One error type: resolver/agent failures flow through ChatErrorAgent;
remove the CEvtSimplexName* events, SimplexNameVerifyFailReason,
SimplexNameConflictEntity and CESimplexNameResolverUnavailable.
APIVerifySimplexName returns CRSimplexNameVerified (verified::Bool),
mirroring CRConnectionVerified. connectPlan handles the name target
directly; updateProfile WithConflict aliases collapsed into the plain
functions.

Add the per-operator "names" SMP server role (migration
20260612_smp_role_names, official operator on by default) feeding
ServerRoles.names -> UserServers.nameSrvs.

Bump simplexmq pin to ce69adfd and regenerate sha256map.nix.

* fix(store): match chat_schema.sql to sqlite 3.46+ indent

The schema-dump test renders the partial-index WHERE via the sqlite3
CLI; sqlite >=3.46 wraps a multi-condition WHERE onto two lines
("IS NOT NULL" + indented "AND ...") where 3.45 kept it on one. The
committed schema was generated with 3.45, so CI (newer sqlite) failed
the comparison on idx_contacts_simplex_name. Regenerated with the
newer formatter; only that one WHERE clause changes.

* feat(operators): warn when no server resolves names

Mirror USWNoChatRelays: validateUserServers emits USWNoNamesServers
when no enabled server of an enabled operator carries the SMP names
role. noNamesServersWarns is self-contained with local predicates,
matching the sibling noChatRelaysWarns; noServersErrs is untouched.

* test(operators): expect USWNoNamesServers warning in no-servers cases

* fix(store): single-line simplex_name WHERE to match CI sqlite (<=3.45)

* chore: bump simplexmq pin to 6843b14c

* refactor(store): consolidate names migrations into one

Unshipped feature - merge the four incremental simplex_name migrations
(0603/0604/0606/0612) into a single M20260603_simplex_name. The combined
UP applies the ALTERs/indexes in the same order, so the resulting schema
is byte-identical (verified by SchemaDump on SQLite and pg_dump on Postgres).

* update simplexmq

* plan for name resolution

* update types and schema

* simpler resolution, name proofs

* simplexmq

* generate bot types, schema, unStrJSON, fix tests

* ad hoc link comparison, create short link

* update simplexmq

* remove same link, use simplexmq instead

* split verify API for contacts and public groups

* remove comment

* simplify warnings

* test: remove unused

* remove cute language

* type name

* remove spurious comments

* refactor setting user name

* refactor setting user name

* remove trivial tests

* refactor

* remove tests using pre-short-link addresses

* rename

* move names

* bots api

* refactor more

* refactor

* refactor to another type

* load own names

* read short links when looking up by name

* renames

* refactor verification

* mapM_

* update api types

* change field for name

* rename columns

* api types, schema

* renames

* fix links

* simplify

* remove comments

* rename fields

* simplify

* remove proof from channel addres

* refactor

* name resolution test

* change tests

* fix tests

* fix tests

* fix plan for names

* test

* test verification status

* bot api

* fix tests

* update bot api types

* query plan

* add api for setting public group access

* android, desktop, ios: connect via SimpleX name (#7068)

* android, desktop, ios: connect via SimpleX name

* android, desktop, ios: open known contact on name lookup; surface prepared contact

Name search opens the contact (not list-filter); resolved/prepared contacts and groups are added to the chat list so they're visible and openable. Kotlin compile-verified; iOS edits pattern-matched, pending Xcode build.

* feat(names): UI names role + agent NAME error

Parity with the core names rework (#7045):

- Add `names` to ServerRoles (Android + iOS) and a per-operator
  "To resolve names" toggle under the SMP section (xftp has no names
  role; the shared ServerRoles field stays false there).
- Mirror the new agent error: NameErrorType + a NAME case on both
  AgentErrorType and ProtocolErrorType (the SMP ErrorType mirror), so
  the new SMP/agent NAME errors decode instead of crashing the decoder.
- Remove ChatErrorType.SimplexNameResolverUnavailable (deleted in core)
  and repoint its "name resolution unavailable" alert to the agent
  NAME NO_SERVERS error, reusing the existing strings.

Android (multiplatform) compiles clean; iOS mirrors the same changes
(builds in Xcode).

* feat(names): UI warning when no server resolves names

Mirror core USWNoNamesServers: add the NoNamesServers variant to
UserServersWarning (Kotlin sealed class + Swift enum) and its
globalWarning / globalServersWarning branch, rendered by the existing
ServersWarningFooter / ServersWarningView. Matches the noChatRelays
warning exactly.

* fix(servers): show all validation errors and warnings, not just the first

globalServersError/Warning returned only the first entry, so a second
warning (e.g. no names servers behind no chat relays) or a second error
(e.g. no XFTP servers behind no SMP servers) was never displayed. Make
them return all entries (globalServersErrors/Warnings) and render one
footer row each, across the three combined-footer views. Per-protocol
SMP/XFTP footers are unchanged.

* docs(names): add SimpleX name UI plan

* feat(names): add name model fields + SimplexName helpers

* feat(names): verify + set-name API & responses

* docs(names): bump core sync to 5008b4e62

* feat(names): show name + verification on chat info

* feat(names): add Verify SimpleX names privacy toggle

* feat(names): add set-name screens (user + channel)

* update ui

* fix kotlin

* fix codable

* fix ios

* fix errors

* api in UI

* send name as string in protocol

* update simplexmq, capitalize

* verify that name is in profile for own and known contacts and channels as condition of name resolution

* update simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* add log

* bot types

* finalize renames, ui alerts

* more renames

* kotlin alerts

* show set name

* alerts

* texts, icons, footers

* move JSON parsing to a persistent thread with large stack

* simplexmq

* use uikit alerts

* remove comment

* move name verification to more privacy

* change icon on verify names setting

* verify name proof

* nix shas

* show verified names

* better alert when saving names

* revert breaking field name change

* simplexmq

* clean up

* remove unused

* remove empty line

* fix error

* simplify

* use domain without prefix in profiles and in database, rename fields and types

* rename types

* fix JSON name

* pass verified domain to prepare API

* fix prepare api

* fix ios encoding

* enable name resolution via Flux servers

* fix test

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-07-03 12:51:52 +01:00

3096 lines
131 KiB
Swift

//
// ChatAPI.swift
// SimpleX
//
// 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
import Dispatch
import BackgroundTasks
import SwiftUI
@preconcurrency import SimpleXChat
private var chatController: chat_ctrl?
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
case res(Date, ChatAPIResult)
case err(Date, ChatError)
case bad(Date, String, Data?)
var id: Date {
switch self {
case let .cmd(d, _): d
case let .res(d, _): d
case let .err(d, _): d
case let .bad(d, _, _): d
}
}
var label: String {
switch self {
case let .cmd(_, cmd): "> \(cmd.cmdString.prefix(30))"
case let .res(_, res): "< \(res.responseType)"
case let .err(_, err): "< error \(err.errorType)"
case let .bad(_, type, _): "< * \(type)"
}
}
var details: String {
switch self {
case let .cmd(_, cmd): cmd.cmdString
case let .res(_, res): res.details
case let .err(_, err): String(describing: err)
case let .bad(_, _, json): dataToString(json)
}
}
}
// Spec: spec/architecture.md#beginBGTask
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
var id: UIBackgroundTaskIdentifier!
var running = true
let endTask = {
// logger.debug("beginBGTask: endTask \(id.rawValue)")
if running {
running = false
if let h = handler {
// logger.debug("beginBGTask: user handler")
h()
}
if id != .invalid {
UIApplication.shared.endBackgroundTask(id)
id = .invalid
}
}
}
id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask)
// logger.debug("beginBGTask: \(id.rawValue)")
return endTask
}
let msgDelay: Double = 7.5
let maxTaskDuration: Double = 15
private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
let endTask = beginBGTask()
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
let r = f()
if let d = bgDelay {
DispatchQueue.global().asyncAfter(deadline: .now() + d, execute: endTask)
} else {
endTask()
}
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)")
}
let start = Date.now
let resp: APIResult<R> = bgTask
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl, retryNum: retryNum) }
: sendSimpleXCmd(cmd, ctrl, retryNum: retryNum)
if log {
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .invalid(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(dataToString(json))")
}
Task {
await TerminalItems.shared.addCommand(start, cmd.obfuscated, resp)
}
}
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,
case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) {
return await withCheckedContinuation { cont in
showRetryAlert(
alert,
onCancel: { _ in
cont.resume(returning: nil)
},
onRetry: {
let r1: APIResult<R>? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1)
cont.resume(returning: r1)
}
)
}
} else {
return r
}
}
@inline(__always)
func showRetryAlert(_ alert: (title: String, message: String), onCancel: @escaping (UIAlertAction) -> Void, onRetry: @escaping () async -> Void) {
DispatchQueue.main.async {
showAlert(
alert.title,
message: alert.message,
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .cancel,
handler: onCancel
),
UIAlertAction(
title: NSLocalizedString("Retry", comment: "alert action"),
style: .default,
handler: { _ in Task(operation: onRetry) }
)
]}
)
}
}
func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: String)? {
switch e {
case let .errorAgent(.BROKER(addr, .TIMEOUT)): (
title: NSLocalizedString("Connection timeout", comment: "alert title"),
message: serverErrorAlertMessage(addr)
)
case let .errorAgent(.BROKER(addr, .NETWORK(.unknownCAError))): nil
case let .errorAgent(.BROKER(addr, .NETWORK)): (
title: NSLocalizedString("Connection error", comment: "alert title"),
message: serverErrorAlertMessage(addr)
)
case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.TIMEOUT)))): (
title: NSLocalizedString("Private routing timeout", comment: "alert title"),
message: proxyErrorAlertMessage(serverAddress)
)
case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK(.unknownCAError))))): nil
case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK)))): (
title: NSLocalizedString("Private routing error", comment: "alert title"),
message: proxyErrorAlertMessage(serverAddress)
)
case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.TIMEOUT))))): (
title: NSLocalizedString("Private routing timeout", comment: "alert title"),
message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
)
case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK(.unknownCAError)))))): nil
case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK))))): (
title: NSLocalizedString("Private routing error", comment: "alert title"),
message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
)
case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.NO_SESSION)))): (
title: NSLocalizedString("No private routing session", comment: "alert title"),
message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer)
)
default: nil
}
}
func serverErrorAlertMessage(_ addr: String) -> String {
String.localizedStringWithFormat(NSLocalizedString("Please check your network connection with %@ and try again.", comment: "alert message"), serverHostname(addr))
}
func proxyErrorAlertMessage(_ addr: String) -> String {
String.localizedStringWithFormat(NSLocalizedString("Error connecting to forwarding server %@. Please try later.", comment: "alert message"), serverHostname(addr))
}
func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) -> 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
cont.resume(returning: chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, retryNum: retryNum, log: log))
}
}
@inline(__always)
func apiResult<R: ChatAPIResult>(_ res: APIResult<R>) throws -> R {
switch res {
case let .result(r): return r
case let .error(e): throw e
case let .invalid(type, _): throw ChatError.unexpectedResult(type: type)
}
}
// Spec: spec/api.md#chatRecvMsg
func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult<ChatEvent>? {
await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) { () -> APIResult<ChatEvent>? in
let evt: APIResult<ChatEvent>? = recvSimpleXMsg(ctrl)
cont.resume(returning: evt)
return evt
}
}
}
func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
let r: APIResult<ChatResponse0> = chatApiSendCmdSync(.showActiveUser, ctrl: ctrl)
switch r {
case let .result(.activeUser(user)): return user
case .error(.error(.noActiveUser)): return nil
default: throw r.unexpected
}
}
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl: ctrl)
if case let .activeUser(user) = r { return user }
throw r.unexpected
}
func listUsers() throws -> [UserInfo] {
return try listUsersResponse(chatSendCmdSync(.listUsers))
}
func listUsersAsync() async throws -> [UserInfo] {
return try listUsersResponse(await chatSendCmd(.listUsers))
}
private func listUsersResponse(_ r: ChatResponse0) throws -> [UserInfo] {
if case let .usersList(users) = r {
return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending }
}
throw r.unexpected
}
func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User {
let r: ChatResponse0 = try chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
if case let .activeUser(user) = r { return user }
throw r.unexpected
}
func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User {
let r: ChatResponse0 = try await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd))
if case let .activeUser(user) = r { return user }
throw r.unexpected
}
func apiSetAllContactReceipts(enable: Bool) async throws {
try await sendCommandOkResp(.setAllContactReceipts(enable: enable))
}
func apiSetUserContactReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
try await sendCommandOkResp(.apiSetUserContactReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
}
func apiSetUserGroupReceipts(_ userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) async throws {
try await sendCommandOkResp(.apiSetUserGroupReceipts(userId: userId, userMsgReceiptSettings: userMsgReceiptSettings))
}
func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async throws {
try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable))
}
func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd))
}
func apiUnhideUser(_ userId: Int64, viewPwd: String) async throws -> User {
try await setUserPrivacy_(.apiUnhideUser(userId: userId, viewPwd: viewPwd))
}
func apiMuteUser(_ userId: Int64) async throws -> User {
try await setUserPrivacy_(.apiMuteUser(userId: userId))
}
func apiUnmuteUser(_ userId: Int64) async throws -> User {
try await setUserPrivacy_(.apiUnmuteUser(userId: userId))
}
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
let r: ChatResponse1 = try await chatSendCmd(cmd)
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
throw r.unexpected
}
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws {
try await sendCommandOkResp(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r: ChatResponse0 = try chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl: ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
default: throw r.unexpected
}
}
func apiCheckChatRunning() throws -> Bool {
let r: ChatResponse0 = try chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r.unexpected
}
}
func apiStopChat() async throws {
let r: ChatResponse0 = try await chatSendCmd(.apiStopChat)
switch r {
case .chatStopped: return
default: throw r.unexpected
}
}
// Spec: spec/architecture.md#apiActivateChat
func apiActivateChat() {
chatReopenStore()
do {
try sendCommandOkRespSync(.apiActivateChat(restoreChat: true))
} catch {
logger.error("apiActivateChat error: \(responseError(error))")
}
}
// Spec: spec/architecture.md#apiSuspendChat
func apiSuspendChat(timeoutMicroseconds: Int) {
do {
try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
} catch {
logger.error("apiSuspendChat error: \(responseError(error))")
}
}
// 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))
}
func apiSaveAppSettings(settings: AppSettings) throws {
try sendCommandOkRespSync(.apiSaveSettings(settings: settings))
}
func apiGetAppSettings(settings: AppSettings) throws -> AppSettings {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetSettings(settings: settings))
if case let .appSettings(settings) = r { return settings }
throw r.unexpected
}
func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
let r: ChatResponse2 = try await chatSendCmd(.apiExportArchive(config: config))
if case let .archiveExported(archiveErrors) = r { return archiveErrors }
throw r.unexpected
}
func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] {
let r: ChatResponse2 = try await chatSendCmd(.apiImportArchive(config: config))
if case let .archiveImported(archiveErrors) = r { return archiveErrors }
throw r.unexpected
}
func apiDeleteStorage() async throws {
try await sendCommandOkResp(.apiDeleteStorage)
}
func apiStorageEncryption(currentKey: String = "", newKey: String = "") async throws {
try await sendCommandOkResp(.apiStorageEncryption(config: DBEncryptionConfig(currentKey: currentKey, newKey: newKey)))
}
func testStorageEncryption(key: String, ctrl: chat_ctrl? = nil) async throws {
try await sendCommandOkResp(.testStorageEncryption(key: key), ctrl: ctrl)
}
func apiGetChats() throws -> [ChatData] {
let userId = try currentUserId("apiGetChats")
return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId)))
}
func apiGetChatsAsync() async throws -> [ChatData] {
let userId = try currentUserId("apiGetChats")
return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId)))
}
private func apiChatsResponse(_ r: ChatResponse0) throws -> [ChatData] {
if case let .apiChats(_, chats) = r { return chats }
throw r.unexpected
}
func apiGetChatTags() throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r: ChatResponse0 = try chatSendCmdSync(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r.unexpected
}
func apiGetChatTagsAsync() async throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r.unexpected
}
let loadItemsPerPage = 50
func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag? = nil, pagination: ChatPagination, search: String = "") async throws -> (Chat, NavigationInfo) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChat(chatId: chatId, scope: scope, contentTag: contentTag, pagination: pagination, search: search))
if case let .apiChat(_, chat, navInfo) = r { return (Chat.init(chat), navInfo ?? NavigationInfo()) }
throw r.unexpected
}
func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope))
if case let .chatContentTypes(types) = r { return types.filter { if case .unknown = $0 { return false }; return true } }
throw r.unexpected
}
func loadChat(chat: Chat, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", clearItems: Bool = true) async {
await loadChat(chatId: chat.chatInfo.id, im: im, contentTag: contentTag, search: search, clearItems: clearItems)
}
func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
await MainActor.run {
if clearItems {
im.reversedChatItems = []
im.chatState.clear()
}
}
await apiLoadMessages(
chatId,
im,
( // pagination
openAroundItemId != nil
? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage)
: (
contentTag == nil && search == ""
? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)
)
),
contentTag,
search,
openAroundItemId,
{ 0...0 }
)
}
func apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) async throws -> ChatItemInfo {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
throw r.unexpected
}
func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ([Int64], ForwardConfirmation?) {
let r: ChatResponse1 = try await chatSendCmd(.apiPlanForwardChatItems(fromChatType: type, fromChatId: id, fromScope: scope, itemIds: itemIds))
if case let .forwardPlan(_, chatItemIds, forwardConfimation) = r { return (chatItemIds, forwardConfimation) }
throw r.unexpected
}
func apiShareChatMsgContent(shareChatType: ChatType, shareChatId: Int64, toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent {
let r: ChatResponse1 = try await chatSendCmd(.apiShareChatMsgContent(shareChatType: shareChatType, shareChatId: shareChatId, toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup))
if case let .chatMsgContent(_, mc) = r { return mc }
throw r.unexpected
}
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] {
let r: ChatResponse0 = try await chatSendCmd(.apiCreateChatTag(tag: tag))
if case let .chatTags(_, userTags) = r {
return userTags
}
throw r.unexpected
}
func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) {
let r: ChatResponse0 = try await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds))
if case let .tagsUpdated(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r.unexpected
}
func apiDeleteChatTag(tagId: Int64) async throws {
try await sendCommandOkResp(.apiDeleteChatTag(tagId: tagId))
}
func apiUpdateChatTag(tagId: Int64, tag: ChatTagData) async throws {
try await sendCommandOkResp(.apiUpdateChatTag(tagId: tagId, tagData: tag))
}
func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
}
private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> [ChatItem]? {
let chatModel = ChatModel.shared
let r: APIResult<ChatResponse1>
if toChatType == .direct {
var cItem: ChatItem? = nil
let endTask = beginBGTask({
if let cItem = cItem {
DispatchQueue.main.async {
chatModel.messageDelivery.removeValue(forKey: cItem.id)
}
}
})
r = await chatApiSendCmd(cmd, bgTask: false)
if case let .result(.newChatItems(_, aChatItems)) = r {
let cItems = aChatItems.map { $0.chatItem }
if let cItemLast = cItems.last {
cItem = cItemLast
chatModel.messageDelivery[cItemLast.id] = endTask
}
return cItems
}
if let networkErrorAlert = networkErrorAlert(r) {
AlertManager.shared.showAlert(networkErrorAlert)
} else {
sendMessageErrorAlert(r.unexpected)
}
endTask()
return nil
} else {
r = await chatApiSendCmd(cmd, bgDelay: msgDelay)
if case let .result(.newChatItems(_, aChatItems)) = r {
return aChatItems.map { $0.chatItem }
}
sendMessageErrorAlert(r.unexpected)
return nil
}
}
func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let r: APIResult<ChatResponse1> = await chatApiSendCmd(.apiCreateChatItems(noteFolderId: noteFolderId, composedMessages: composedMessages))
if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } }
createChatItemsErrorAlert(r.unexpected)
return nil
}
func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? {
let r: APIResult<ChatResponse1> = await chatApiSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText))
if case let .result(.newChatItems(_, aChatItems)) = r { return aChatItems.map { $0.chatItem } }
logger.error("apiReportMessage error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating report",
message: "Error: \(responseError(r.unexpected))"
)
return nil
}
private func sendMessageErrorAlert(_ r: ChatError) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
message: "Error: \(responseError(r))"
)
}
private func createChatItemsErrorAlert(_ r: ChatError) {
logger.error("apiCreateChatItems error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(responseError(r))"
)
}
func apiUpdateChatItem(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, updatedMessage: UpdatedMessage, live: Bool = false) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiUpdateChatItem(type: type, id: id, scope: scope, itemId: itemId, updatedMessage: updatedMessage, live: live), bgDelay: msgDelay)
switch r {
case let .chatItemUpdated(_, aChatItem): return aChatItem.chatItem
case let .chatItemNotChanged(_, aChatItem): return aChatItem.chatItem
default: throw r.unexpected
}
}
func apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) async throws -> ChatItem {
let r: ChatResponse1 = try await chatSendCmd(.apiChatItemReaction(type: type, id: id, scope: scope, itemId: itemId, add: add, reaction: reaction), bgDelay: msgDelay)
if case let .chatItemReaction(_, _, reaction) = r { return reaction.chatReaction.chatItem }
throw r.unexpected
}
func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
let userId = try currentUserId("apiGetReactionMemebers")
let r: ChatResponse1 = try await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
if case let .reactionMembers(_, memberReactions) = r { return memberReactions }
throw r.unexpected
}
func apiDeleteChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChatItem(type: type, id: id, scope: scope, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r.unexpected
}
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r.unexpected
}
func apiArchiveReceivedReports(groupId: Int64) async throws -> ChatResponse1 {
let r: ChatResponse1 = try await chatSendCmd(.apiArchiveReceivedReports(groupId: groupId), bgDelay: msgDelay)
if case .groupChatItemsDeleted = r { return r }
throw r.unexpected
}
func apiDeleteReceivedReports(groupId: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteReceivedReports(groupId: groupId, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, chatItemDeletions, _) = r { return chatItemDeletions }
throw r.unexpected
}
func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode, String?) {
let r: APIResult<ChatResponse2> = chatApiSendCmdSync(.apiGetNtfToken)
switch r {
case let .result(.ntfToken(token, status, ntfMode, ntfServer)): return (token, status, ntfMode, ntfServer)
case .error(.errorAgent(.CMD(.PROHIBITED, _))): return (nil, nil, .off, nil)
default:
logger.debug("apiGetNtfToken response: \(String(describing: r))")
return (nil, nil, .off, nil)
}
}
func apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) async throws -> NtfTknStatus {
let r: ChatResponse2 = try await chatSendCmd(.apiRegisterToken(token: token, notificationMode: notificationMode))
if case let .ntfTokenStatus(status) = r { return status }
throw r.unexpected
}
func registerToken(token: DeviceToken) {
let m = ChatModel.shared
let mode = m.notificationMode
if mode != .off && !m.tokenRegistered {
m.tokenRegistered = true
logger.debug("registerToken \(mode.rawValue)")
Task {
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run {
m.tokenStatus = status
if !status.workingToken {
m.reRegisterTknStatus = status
}
}
} catch let error {
logger.error("registerToken apiRegisterToken error: \(responseError(error))")
}
}
}
}
func tokenStatusInfo(_ status: NtfTknStatus, register: Bool) -> String {
String.localizedStringWithFormat(NSLocalizedString("Token status: %@.", comment: "token status"), status.text)
+ "\n" + status.info(register: register)
}
func reRegisterToken(token: DeviceToken) {
let m = ChatModel.shared
let mode = m.notificationMode
logger.debug("reRegisterToken \(mode.rawValue)")
Task {
do {
let status = try await apiRegisterToken(token: token, notificationMode: mode)
await MainActor.run {
m.tokenStatus = status
showAlert(
status.workingToken
? NSLocalizedString("Notifications status", comment: "alert title")
: NSLocalizedString("Notifications error", comment: "alert title"),
message: tokenStatusInfo(status, register: false)
)
}
} catch let error {
logger.error("reRegisterToken apiRegisterToken error: \(responseError(error))")
await MainActor.run {
showAlert(
NSLocalizedString("Error registering for notifications", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
func apiVerifyToken(token: DeviceToken, nonce: String, code: String) async throws {
try await sendCommandOkResp(.apiVerifyToken(token: token, nonce: nonce, code: code))
}
func apiCheckToken(token: DeviceToken) async throws -> NtfTknStatus {
let r: ChatResponse2 = try await chatSendCmd(.apiCheckToken(token: token))
if case let .ntfTokenStatus(status) = r { return status }
throw r.unexpected
}
func apiDeleteToken(token: DeviceToken) async throws {
try await sendCommandOkResp(.apiDeleteToken(token: token))
}
func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFailure> {
let userId = try currentUserId("testProtoServer")
let r: ChatResponse0 = try await chatSendCmd(.apiTestProtoServer(userId: userId, server: server))
if case let .serverTestResult(_, _, testFailure) = r {
if let t = testFailure {
return .failure(t)
}
return .success(())
}
throw r.unexpected
}
func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) {
let userId = try currentUserId("testChatRelay")
let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address))
if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r {
return (relayProfile, relayTestFailure)
}
throw r.unexpected
}
func getServerOperators() async throws -> ServerOperatorConditions {
let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
throw r.unexpected
}
func getServerOperatorsSync() throws -> ServerOperatorConditions {
let r: ChatResponse0 = try chatSendCmdSync(.apiGetServerOperators)
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("getServerOperators error: \(String(describing: r))")
throw r.unexpected
}
func setServerOperators(operators: [ServerOperator]) async throws -> ServerOperatorConditions {
let r: ChatResponse0 = try await chatSendCmd(.apiSetServerOperators(operators: operators))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("setServerOperators error: \(String(describing: r))")
throw r.unexpected
}
func getUserServers() async throws -> [UserOperatorServers] {
let userId = try currentUserId("getUserServers")
let r: ChatResponse0 = try await chatSendCmd(.apiGetUserServers(userId: userId))
if case let .userServers(_, userServers) = r { return userServers }
logger.error("getUserServers error: \(String(describing: r))")
throw r.unexpected
}
func setUserServers(userServers: [UserOperatorServers]) async throws {
let userId = try currentUserId("setUserServers")
let r: ChatResponse2 = try await chatSendCmd(.apiSetUserServers(userId: userId, userServers: userServers))
if case .cmdOk = r { return }
logger.error("setUserServers error: \(String(describing: r))")
throw r.unexpected
}
func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) {
let userId = try currentUserId("validateServers")
let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) }
logger.error("validateServers error: \(String(describing: r))")
throw r.unexpected
}
func getUsageConditions() async throws -> (UsageConditions, String?, UsageConditions?) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetUsageConditions)
if case let .usageConditions(usageConditions, conditionsText, acceptedConditions) = r { return (usageConditions, conditionsText, acceptedConditions) }
logger.error("getUsageConditions error: \(String(describing: r))")
throw r.unexpected
}
func setConditionsNotified(conditionsId: Int64) async throws {
let r: ChatResponse2 = try await chatSendCmd(.apiSetConditionsNotified(conditionsId: conditionsId))
if case .cmdOk = r { return }
logger.error("setConditionsNotified error: \(String(describing: r))")
throw r.unexpected
}
func acceptConditions(conditionsId: Int64, operatorIds: [Int64]) async throws -> ServerOperatorConditions {
let r: ChatResponse0 = try await chatSendCmd(.apiAcceptConditions(conditionsId: conditionsId, operatorIds: operatorIds))
if case let .serverOperatorConditions(conditions) = r { return conditions }
logger.error("acceptConditions error: \(String(describing: r))")
throw r.unexpected
}
func getChatItemTTL() throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTL")
return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId)))
}
func getChatItemTTLAsync() async throws -> ChatItemTTL {
let userId = try currentUserId("getChatItemTTLAsync")
return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId)))
}
private func chatItemTTLResponse(_ r: ChatResponse0) throws -> ChatItemTTL {
if case let .chatItemTTL(_, chatItemTTL) = r {
if let ttl = chatItemTTL {
return ChatItemTTL(ttl)
} else {
throw RuntimeError("chatItemTTLResponse: invalid ttl")
}
}
throw r.unexpected
}
func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
let userId = try currentUserId("setChatItemTTL")
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
}
func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws {
let userId = try currentUserId("setChatItemTTL")
try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value))
}
func getNetworkConfig() async throws -> NetCfg? {
let r: ChatResponse0 = try await chatSendCmd(.apiGetNetworkConfig)
if case let .networkConfig(cfg) = r { return cfg }
throw r.unexpected
}
func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws {
let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkConfig(networkConfig: cfg), ctrl: ctrl)
if case .cmdOk = r { return }
throw r.unexpected
}
func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws {
let r: ChatResponse2 = try chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo))
if case .cmdOk = r { return }
throw r.unexpected
}
func reconnectAllServers() async throws {
try await sendCommandOkResp(.reconnectAllServers)
}
func reconnectServer(smpServer: String) async throws {
let userId = try currentUserId("reconnectServer")
try await sendCommandOkResp(.reconnectServer(userId: userId, smpServer: smpServer))
}
func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) async throws {
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws {
try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings))
}
func apiGetUpdatedGroupLinkData(_ groupId: Int64) async -> GroupInfo? {
let r: APIResult<ChatResponse0> = await chatApiSendCmd(.apiGetUpdatedGroupLinkData(groupId: groupId))
if case let .result(.groupInfo(_, groupInfo)) = r { return groupInfo }
return nil
}
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
throw r.unexpected
}
func apiGroupMemberInfoSync(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, ConnectionStats?) {
let r: ChatResponse0 = try chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
throw r.unexpected
}
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, ConnectionStats?) {
let r: ChatResponse0 = try await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, member, connStats_) = r { return (member, connStats_) }
throw r.unexpected
}
func apiContactQueueInfo(_ contactId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
let r: APIResult<ChatResponse0>? = await chatApiSendCmdWithRetry(.apiContactQueueInfo(contactId: contactId))
if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
if let r { throw r.unexpected } else { return nil }
}
func apiGroupMemberQueueInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (RcvMsgInfo?, ServerQueueInfo)? {
let r: APIResult<ChatResponse0>? = await chatApiSendCmdWithRetry(.apiGroupMemberQueueInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .result(.queueInfo(_, rcvMsgInfo, queueInfo)) = r { return (rcvMsgInfo, queueInfo) }
if let r { throw r.unexpected } else { return nil }
}
func apiSwitchContact(contactId: Int64) throws -> ConnectionStats {
let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchContact(contactId: contactId))
if case let .contactSwitchStarted(_, _, connectionStats) = r { return connectionStats }
throw r.unexpected
}
func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
let r: ChatResponse0 = try chatSendCmdSync(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberSwitchStarted(_, _, _, connectionStats) = r { return connectionStats }
throw r.unexpected
}
func apiAbortSwitchContact(_ contactId: Int64) throws -> ConnectionStats {
let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchContact(contactId: contactId))
if case let .contactSwitchAborted(_, _, connectionStats) = r { return connectionStats }
throw r.unexpected
}
func apiAbortSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) throws -> ConnectionStats {
let r: ChatResponse0 = try chatSendCmdSync(.apiAbortSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberSwitchAborted(_, _, _, connectionStats) = r { return connectionStats }
throw r.unexpected
}
func apiSyncContactRatchet(_ contactId: Int64, _ force: Bool) throws -> ConnectionStats {
let r: ChatResponse0 = try chatSendCmdSync(.apiSyncContactRatchet(contactId: contactId, force: force))
if case let .contactRatchetSyncStarted(_, _, connectionStats) = r { return connectionStats }
throw r.unexpected
}
func apiSyncGroupMemberRatchet(_ groupId: Int64, _ groupMemberId: Int64, _ force: Bool) throws -> (GroupMember, ConnectionStats) {
let r: ChatResponse0 = try chatSendCmdSync(.apiSyncGroupMemberRatchet(groupId: groupId, groupMemberId: groupMemberId, force: force))
if case let .groupMemberRatchetSyncStarted(_, _, member, connectionStats) = r { return (member, connectionStats) }
throw r.unexpected
}
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetContactCode(contactId: contactId))
if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) }
throw r.unexpected
}
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupMember, String) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) }
throw r.unexpected
}
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
let r: APIResult<ChatResponse0> = chatApiSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) }
logger.error("apiVerifyContact error: \(String(describing: r))")
return nil
}
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
let r: APIResult<ChatResponse0> = chatApiSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
if case let .result(.connectionVerified(_, verified, expectedCode)) = r { return (verified, expectedCode) }
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
return nil
}
func apiAddContact(incognito: Bool) async -> ((CreatedConnLink, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiAddContact(userId: userId, incognito: incognito), bgTask: false)
if case let .result(.invitation(_, connLinkInv, connection)) = r { return ((connLinkInv, connection), nil) }
let alert: Alert? = if let r { connectionErrorAlert(r) } else { nil }
return (nil, alert)
}
func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito))
if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection }
throw r.unexpected
}
func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> PendingContactConnection? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiChangeConnectionUser(connId: connId, userId: userId))
if case let .result(.connectionUserChanged(_, _, toConnection, _)) = r {return toConnection}
if let r { throw r.unexpected } else { return nil }
}
func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) }
let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
}
func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? {
let (r, alert) = await apiConnect_(incognito: incognito, connLink: connLink)
if let alert = alert {
AlertManager.shared.showAlert(alert)
return nil
} else {
return r
}
}
func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqType, PendingContactConnection)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
let m = ChatModel.shared
switch r {
case let .result(.sentConfirmation(_, connection)):
return ((.invitation, connection), nil)
case let .result(.sentInvitation(_, connection)):
return ((.contact, connection), nil)
case let .result(.contactAlreadyExists(_, contact)):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
}
let alert = contactAlreadyExistsAlert(contact)
return (nil, alert)
default: ()
}
let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil }
return (nil, alert)
}
private func apiConnectResponseAlert<R>(_ r: APIResult<R>) -> Alert {
switch r.unexpected {
case .error(.invalidConnReq):
mkAlert(
title: "Invalid connection link",
message: "Please check that you used the correct link or ask your contact to send you another one."
)
case .error(.unsupportedConnReq):
mkAlert(
title: "Unsupported connection link",
message: "This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link."
)
case let .error(.simplexDomainNotReady(domain, err)):
switch err {
case .noValidLink:
mkAlert(
title: "No valid link",
message: "The SimpleX name \(domain.fullDomainName) is registered, but it has no valid link."
)
case .unknownDomain:
mkAlert(
title: "Unconfirmed name",
message: "The SimpleX name \(domain.fullDomainName) is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner."
)
}
case .errorAgent(.NO_NAME_SERVERS):
mkAlert(
title: "SimpleX name error",
message: "None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link."
)
case .errorAgent(.SMP(_, .AUTH)):
mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
case let .errorAgent(.SMP(_, .BLOCKED(info))):
Alert(
title: Text("Connection blocked"),
message: Text("Connection is blocked by server operator:\n\(info.reason.text)"),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("How it works")) {
DispatchQueue.main.async {
UIApplication.shared.open(contentModerationPostLink)
}
}
)
case .errorAgent(.SMP(_, .QUOTA)):
mkAlert(
title: "Undelivered messages",
message: "The connection reached the limit of undelivered messages, your contact may be offline."
)
case let .errorAgent(.INTERNAL(internalErr)):
if internalErr == "SEUniqueID" {
mkAlert(
title: "Already connected?",
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(internalErr))."
)
} else {
connectionErrorAlert(r)
}
case let .errorAgent(.SMP(serverAddress, .NAME(nameErr))):
switch nameErr {
case .NOT_FOUND:
mkAlert(
title: "Name not found",
message: "This SimpleX name is not registered. Please check the name."
)
case .NO_RESOLVER:
mkAlert(
title: "SimpleX name error",
message: "Server \(serverAddress) does not support name resolution. Configure servers, or use a connection link."
)
case let .RESOLVER(resolverErr):
mkAlert(
title: "SimpleX name error",
message: "Resolver error: \(resolverErr)"
)
}
default: connectionErrorAlert(r)
}
}
func connErrorText(_ e: ChatError) -> String {
switch e {
case .error(.invalidConnReq):
NSLocalizedString("Invalid connection link", comment: "conn error description")
case .error(.unsupportedConnReq):
NSLocalizedString("Unsupported connection link", comment: "conn error description")
case .errorAgent(.SMP(_, .AUTH)):
NSLocalizedString("Connection error (AUTH)", comment: "conn error description")
case let .errorAgent(.SMP(_, .BLOCKED(info))):
NSLocalizedString("Connection blocked: \(info.reason.text)", comment: "conn error description")
case .errorAgent(.SMP(_, .QUOTA)):
NSLocalizedString("The connection reached the limit of undelivered messages", comment: "conn error description")
default:
if getNetworkErrorAlert(e) != nil {
NSLocalizedString("Network error", comment: "conn error description")
} else {
"\(NSLocalizedString("Error", comment: "conn error description")): \(responseError(e))"
}
}
}
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connected to \(contact.displayName)."
)
}
private func connectionErrorAlert<R>(_ r: APIResult<R>) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert
} else {
return mkAlert(
title: "Connection error",
message: "Error: \(responseError(r.unexpected))"
)
}
}
func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData {
let userId = try currentUserId("apiPrepareContact")
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareContact(userId: userId, connLink: connLink, contactShortLinkData: contactShortLinkData, verifiedDomain: verifiedDomain))
if case let .newPreparedChat(_, chat) = r { return chat }
throw r.unexpected
}
func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain? = nil) async throws -> ChatData {
let userId = try currentUserId("apiPrepareGroup")
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData, verifiedDomain: verifiedDomain))
if case let .newPreparedChat(_, chat) = r { return chat }
throw r.unexpected
}
func apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) async throws -> Contact {
let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedContactUser(contactId: contactId, newUserId: newUserId))
if case let .contactUserChanged(_, _, _, toContact) = r {return toContact}
throw r.unexpected
}
func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -> GroupInfo {
let r: ChatResponse1 = try await chatSendCmd(.apiChangePreparedGroupUser(groupId: groupId, newUserId: newUserId))
if case let .groupUserChanged(_, _, _, toGroup) = r {return toGroup}
throw r.unexpected
}
func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg))
if case let .result(.startedConnectionToContact(_, contact)) = r { return contact }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
return nil
}
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
return nil
}
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Contact?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectContactViaAddress: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return (contact, nil) }
if let r {
logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
return (nil, connectionErrorAlert(r))
} else {
return (nil, nil)
}
}
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
throw r.unexpected
}
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r.unexpected
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
}
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactChat apiDeleteContact error: \(responseError(error))")
return mkAlert(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
return nil
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r: ChatResponse1 = try await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
throw r.unexpected
}
func clearChat(_ chat: Chat) async {
do {
let cInfo = chat.chatInfo
let updatedChatInfo = try await apiClearChat(type: cInfo.chatType, id: cInfo.apiId)
DispatchQueue.main.async { ChatModel.shared.clearChat(updatedChatInfo) }
} catch {
logger.error("clearChat apiClearChat error: \(responseError(error))")
}
}
func apiListContacts() throws -> [Contact] {
let userId = try currentUserId("apiListContacts")
let r: ChatResponse1 = try chatSendCmdSync(.apiListContacts(userId: userId))
if case let .contactsList(_, contacts) = r { return contacts }
throw r.unexpected
}
func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
let userId = try currentUserId("apiUpdateProfile")
let r: APIResult<ChatResponse1> = await chatApiSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
switch r {
case .result(.userProfileNoChange): return (profile, [])
case let .result(.userProfileUpdated(_, _, toProfile, updateSummary)): return (toProfile, updateSummary.changedContacts)
case .error(.errorStore(.duplicateName)): return nil;
default: throw r.unexpected
}
}
func apiSetProfileAddress(on: Bool) async throws -> User? {
let userId = try currentUserId("apiSetProfileAddress")
let r: ChatResponse1 = try await chatSendCmd(.apiSetProfileAddress(userId: userId, on: on))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(user, _, _, _): return user
default: throw r.unexpected
}
}
// name is the encoded SimplexName (e.g. "@alice.simplex"); nil clears it
// owner-specific SNENoValidLink wording; everything else reuses the general apiConnectResponseAlert
func showSetSimplexNameError<R>(_ r: APIResult<R>, isChannel: Bool) {
if case let .error(.simplexDomainNotReady(domain, .noValidLink)) = r.unexpected {
let format = isChannel
? NSLocalizedString("The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page.", comment: "alert message")
: NSLocalizedString("The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page.", comment: "alert message")
showAlert(NSLocalizedString("Error saving name", comment: "alert title"), message: String.localizedStringWithFormat(format, domain.fullDomainName))
} else {
AlertManager.shared.showAlert(apiConnectResponseAlert(r))
}
}
func apiSetUserDomain(_ simplexDomain: String?) async throws -> User {
let userId = try currentUserId("apiSetUserDomain")
let r: APIResult<ChatResponse1> = await chatApiSendCmd(.apiSetUserDomain(userId: userId, simplexDomain: simplexDomain))
switch r {
case let .result(.userProfileUpdated(user, _, _, _)): return user
case let .result(.userProfileNoChange(user)): return user
default:
showSetSimplexNameError(r, isChannel: false)
throw r.unexpected
}
}
func apiVerifyContactDomain(_ contactId: Int64) async throws -> (Contact, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyContactDomain(contactId: contactId))
if case let .contactDomainVerified(_, contact, verificationFailure) = r { return (contact, verificationFailure) }
throw r.unexpected
}
func apiVerifyGroupDomain(_ groupId: Int64) async throws -> (GroupInfo, String?) {
let r: ChatResponse2 = try await chatSendCmd(.apiVerifyGroupDomain(groupId: groupId))
if case let .groupDomainVerified(_, groupInfo, verificationFailure) = r { return (groupInfo, verificationFailure) }
throw r.unexpected
}
func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences))
if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact }
throw r.unexpected
}
func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias))
if case let .contactAliasUpdated(_, toContact) = r { return toContact }
throw r.unexpected
}
func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
if case let .groupAliasUpdated(_, toGroup) = r { return toGroup }
throw r.unexpected
}
func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? {
let r: ChatResponse1 = try await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection }
throw r.unexpected
}
func apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?) async -> Bool {
do {
try await sendCommandOkResp(.apiSetUserUIThemes(userId: userId, themes: themes))
return true
} catch {
logger.error("apiSetUserUIThemes bad response: \(responseError(error))")
return false
}
}
func apiSetChatUIThemes(chatId: ChatId, themes: ThemeModeOverrides?) async -> Bool {
do {
try await sendCommandOkResp(.apiSetChatUIThemes(chatId: chatId, themes: themes))
return true
} catch {
logger.error("apiSetChatUIThemes bad response: \(responseError(error))")
return false
}
}
func apiCreateUserAddress() async throws -> CreatedConnLink? {
let userId = try currentUserId("apiCreateUserAddress")
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId))
if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink }
if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r {
showClientNotice(server, preset, expires)
return nil
}
if let r { throw r.unexpected } else { return nil }
}
func apiDeleteUserAddress() async throws -> User? {
let userId = try currentUserId("apiDeleteUserAddress")
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiDeleteMyAddress(userId: userId))
if case let .result(.userContactLinkDeleted(user)) = r { return user }
if let r { throw r.unexpected } else { return nil }
}
func apiGetUserAddress() throws -> UserContactLink? {
let userId = try currentUserId("apiGetUserAddress")
return try userAddressResponse(chatApiSendCmdSync(.apiShowMyAddress(userId: userId)))
}
func apiGetUserAddressAsync() async throws -> UserContactLink? {
let userId = try currentUserId("apiGetUserAddressAsync")
return try userAddressResponse(await chatApiSendCmd(.apiShowMyAddress(userId: userId)))
}
private func userAddressResponse(_ r: APIResult<ChatResponse1>) throws -> UserContactLink? {
switch r {
case let .result(.userContactLink(_, contactLink)): return contactLink
case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil
default: throw r.unexpected
}
}
func apiAddMyAddressShortLink() async throws -> UserContactLink? {
let userId = try currentUserId("apiAddMyAddressShortLink")
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiAddMyAddressShortLink(userId: userId))
if case let .result(.userContactLink(_, contactLink)) = r { return contactLink }
if let r { throw r.unexpected } else { return nil }
}
func apiSetUserAddressSettings(_ settings: AddressSettings) async throws -> UserContactLink? {
let userId = try currentUserId("apiSetUserAddressSettings")
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiSetAddressSettings(userId: userId, addressSettings: settings))
switch r {
case let .result(.userContactLinkUpdated(_, contactLink)): return contactLink
case .error(.errorStore(storeError: .userContactLinkNotFound)): return nil
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? {
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId))
let am = AlertManager.shared
if case let .result(.acceptingContactRequest(_, contact)) = r { return contact }
if case .error(.errorAgent(.SMP(_, .AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
)
} else if let r {
if let networkErrorAlert = networkErrorAlert(r) {
am.showAlert(networkErrorAlert)
} else {
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
message: "Error: \(responseError(r.unexpected))"
)
}
}
return nil
}
func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? {
let r: ChatResponse1 = try await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case let .contactRequestRejected(_, _, contact_) = r { return contact_ }
throw r.unexpected
}
func apiChatRead(type: ChatType, id: Int64) async throws {
try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil))
}
func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) {
let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope))
if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) }
throw r.unexpected
}
func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo {
let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds))
if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo }
throw r.unexpected
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
}
func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (FileTransferMeta?, String?) {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiUploadStandaloneFile(userId: user.userId, file: file), ctrl: ctrl)
if case let .result(.sndStandaloneFileCreated(_, fileTransferMeta)) = r {
return (fileTransferMeta, nil)
} else {
let err = responseError(r.unexpected)
logger.error("uploadStandaloneFile error: \(err)")
return (nil, err)
}
}
func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, ctrl: chat_ctrl? = nil) async -> (RcvFileTransfer?, String?) {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiDownloadStandaloneFile(userId: user.userId, url: url, file: file), ctrl: ctrl)
if case let .result(.rcvStandaloneFileCreated(_, rcvFileTransfer)) = r {
return (rcvFileTransfer, nil)
} else {
let err = responseError(r.unexpected)
logger.error("downloadStandaloneFile error: \(err)")
return (nil, err)
}
}
func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationFileLinkData? {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiStandaloneFileInfo(url: url), ctrl: ctrl)
if case let .result(.standaloneFileInfo(fileMeta)) = r {
return fileMeta
} else {
logger.error("standaloneFileInfo error: \(responseError(r.unexpected))")
return nil
}
}
// Spec: spec/services/files.md#receiveFile
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
await receiveFiles(
user: user,
fileIds: [fileId],
userApprovedRelays: userApprovedRelays,
auto: auto
)
}
func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool = false, auto: Bool = false) async {
var fileIdsToApprove: [Int64] = []
var srvsToApprove: Set<String> = []
var otherFileErrs: [APIResult<ChatResponse2>] = []
for fileId in fileIds {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(
.receiveFile(
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
inline: nil
)
)
switch r {
case let .result(.rcvFileAccepted(_, chatItem)):
await chatItemSimpleUpdate(user, chatItem)
// TODO when aChatItem added
// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _):
// await chatItemSimpleUpdate(user, aChatItem)
// Task { cleanupFile(aChatItem) }
case let .error(.error(.fileNotApproved(fileId, unknownServers))):
fileIdsToApprove.append(fileId)
srvsToApprove.formUnion(unknownServers)
default:
otherFileErrs.append(r)
}
}
if !auto {
let otherErrsStr = fileErrorStrs(otherFileErrs)
// If there are not approved files, alert is shown the same way both in case of singular and plural files reception
if !fileIdsToApprove.isEmpty {
let srvs = srvsToApprove
.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
.sorted()
.joined(separator: ", ")
let fIds = fileIdsToApprove
await MainActor.run {
showAlert(
title: NSLocalizedString("Unknown servers!", comment: "alert title"),
message: (
String.localizedStringWithFormat(NSLocalizedString("Without Tor or VPN, your IP address will be visible to these XFTP relays: %@.", comment: "alert message"), srvs) +
(otherErrsStr != "" ? "\n\n" + String.localizedStringWithFormat(NSLocalizedString("Other file errors:\n%@", comment: "alert message"), otherErrsStr) : "")
),
buttonTitle: NSLocalizedString("Download", comment: "alert button"),
buttonAction: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFiles(user: user, fileIds: fIds, userApprovedRelays: true)
}
}
},
cancelButton: true
)
}
} else if otherFileErrs.count == 1 { // If there is a single other error, we differentiate on it
let errorResponse = otherFileErrs.first!
switch errorResponse {
case let .result(.rcvFileAcceptedSndCancelled(_, rcvFileTransfer)):
logger.debug("receiveFiles error: sender cancelled file transfer \(rcvFileTransfer.fileId)")
await MainActor.run {
showAlert(
NSLocalizedString("Cannot receive file", comment: "alert title"),
message: NSLocalizedString("Sender cancelled file transfer.", comment: "alert message")
)
}
case .error(.error(.fileCancelled)), .error(.error(.fileAlreadyReceiving)):
logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
default:
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: responseError(errorResponse.unexpected)
)
}
}
} else if otherFileErrs.count > 1 { // If there are multiple other errors, we show general alert
await MainActor.run {
showAlert(
NSLocalizedString("Error receiving file", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("File errors:\n%@", comment: "alert message"), otherErrsStr)
)
}
}
}
func fileErrorStrs(_ errs: [APIResult<ChatResponse2>]) -> String {
var errStr = ""
if errs.count >= 1 {
errStr = String(describing: errs[0].unexpected)
}
if errs.count >= 2 {
errStr += "\n\(String(describing: errs[1].unexpected))"
}
if errs.count > 2 {
errStr += "\nand \(errs.count - 2) other error(s)"
}
return errStr
}
}
// Spec: spec/services/files.md#cancelFile
func cancelFile(user: User, fileId: Int64) async {
if let chatItem = await apiCancelFile(fileId: fileId) {
await chatItemSimpleUpdate(user, chatItem)
cleanupFile(chatItem)
}
}
func apiCancelFile(fileId: Int64, ctrl: chat_ctrl? = nil) async -> AChatItem? {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.cancelFile(fileId: fileId), ctrl: ctrl)
switch r {
case let .result(.sndFileCancelled(_, chatItem, _, _)) : return chatItem
case let .result(.rcvFileCancelled(_, chatItem, _)) : return chatItem
default:
logger.error("apiCancelFile error: \(responseError(r.unexpected))")
return nil
}
}
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)
}
func confirmRemoteCtrl(_ rcId: Int64) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
let r: ChatResponse2 = try await chatSendCmd(.confirmRemoteCtrl(remoteCtrlId: rcId))
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
throw r.unexpected
}
func verifyRemoteCtrlSession(_ sessCode: String) async throws -> RemoteCtrlInfo {
let r: ChatResponse2 = try await chatSendCmd(.verifyRemoteCtrlSession(sessionCode: sessCode))
if case let .remoteCtrlConnected(rc) = r { return rc }
throw r.unexpected
}
func listRemoteCtrls() throws -> [RemoteCtrlInfo] {
let r: ChatResponse2 = try chatSendCmdSync(.listRemoteCtrls)
if case let .remoteCtrlList(rcInfo) = r { return rcInfo }
throw r.unexpected
}
func stopRemoteCtrl() async throws {
try await sendCommandOkResp(.stopRemoteCtrl)
}
func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
func networkErrorAlert<R>(_ res: APIResult<R>) -> Alert? {
if case let .error(e) = res, let alert = getNetworkErrorAlert(e) {
return mkAlert(title: alert.title, message: alert.message)
} else {
return nil
}
}
func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: Binding<Bool>? = nil) async {
await MainActor.run { inProgress?.wrappedValue = true }
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequestId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
await MainActor.run {
if contact.contactRequestId != nil { // means contact request was initially created with contact, so we don't need to replace it
ChatModel.shared.updateContact(contact)
} else {
ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat)
}
inProgress?.wrappedValue = false
}
if contact.sndReady {
let chatId = chat.id
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chatId)
}
}
}
} else {
await MainActor.run { inProgress?.wrappedValue = false }
}
}
func rejectContactRequest(_ contactRequestId: Int64, dismissToChatList: Bool = false) async {
do {
let contact_ = try await apiRejectContactRequest(contactReqId: contactRequestId)
await MainActor.run {
if let contact = contact_ { // means contact request was initially created with contact, so we need to remove contact chat
ChatModel.shared.removeChat(contact.id)
} else {
ChatModel.shared.removeChat(contactRequestChatId(contactRequestId))
}
if dismissToChatList {
ChatModel.shared.chatId = nil
}
}
} catch let error {
logger.error("rejectContactRequest: \(responseError(error))")
await MainActor.run {
showAlert(
NSLocalizedString("Error rejecting contact request", comment: "alert title"),
message: responseError(error)
)
}
}
}
func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws {
try await sendCommandOkResp(.apiSendCallInvitation(contact: contact, callType: callType))
}
func apiRejectCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiRejectCall(contact: contact))
}
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, media: CallMediaType, capabilities: CallCapabilities) async throws {
let webRtcSession = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
let callOffer = WebRTCCallOffer(callType: CallType(media: media, capabilities: capabilities), rtcSession: webRtcSession)
try await sendCommandOkResp(.apiSendCallOffer(contact: contact, callOffer: callOffer))
}
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws {
let answer = WebRTCSession(rtcSession: rtcSession, rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallAnswer(contact: contact, answer: answer))
}
func apiSendCallExtraInfo(_ contact: Contact, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcIceCandidates: rtcIceCandidates)
try await sendCommandOkResp(.apiSendCallExtraInfo(contact: contact, extraInfo: extraInfo))
}
func apiEndCall(_ contact: Contact) async throws {
try await sendCommandOkResp(.apiEndCall(contact: contact))
}
func apiGetCallInvitationsSync() throws -> [RcvCallInvitation] {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r.unexpected
}
func apiGetCallInvitations() async throws -> [RcvCallInvitation] {
let r: ChatResponse2 = try await chatSendCmd(.apiGetCallInvitations)
if case let .callInvitations(invs) = r { return invs }
throw r.unexpected
}
func apiCallStatus(_ contact: Contact, _ status: String) async throws {
if let callStatus = WebRTCCallStatus.init(rawValue: status) {
try await sendCommandOkResp(.apiCallStatus(contact: contact, callStatus: callStatus))
} else {
logger.debug("apiCallStatus: call status \(status) not used")
}
}
func markChatRead(_ im: ItemsModel, _ chat: Chat) async {
do {
if chat.chatStats.unreadCount > 0 {
let cInfo = chat.chatInfo
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId)
await MainActor.run {
withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) }
}
}
if chat.chatStats.unreadChat {
await markChatUnread(chat, unreadChat: false)
}
} catch {
logger.error("markChatRead apiChatRead error: \(responseError(error))")
}
}
func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
do {
let cInfo = chat.chatInfo
try await apiChatUnread(type: cInfo.chatType, id: cInfo.apiId, unreadChat: unreadChat)
await MainActor.run {
withAnimation { ChatModel.shared.markChatUnread(cInfo, unreadChat: unreadChat) }
}
} catch {
logger.error("markChatUnread apiChatUnread error: \(responseError(error))")
}
}
func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async {
do {
if member.supportChatNotRead {
let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId))
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember)
ChatModel.shared.updateGroup(updatedGroupInfo)
}
}
} catch {
logger.error("markSupportChatRead apiChatRead error: \(responseError(error))")
}
}
func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async {
do {
let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds)
await MainActor.run {
ChatModel.shared.updateChatInfo(updatedChatInfo)
ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
}
}
private func sendCommandOkResp(_ cmd: ChatCommand, ctrl: chat_ctrl? = nil) async throws {
let r: ChatResponse2 = try await chatSendCmd(cmd, ctrl: ctrl)
if case .cmdOk = r { return }
throw r.unexpected
}
private func sendCommandOkRespSync(_ cmd: ChatCommand) throws {
let r: ChatResponse2 = try chatSendCmdSync(cmd)
if case .cmdOk = r { return }
throw r.unexpected
}
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
let userId = try currentUserId("apiNewGroup")
let r: ChatResponse2 = try chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
throw r.unexpected
}
enum PublicGroupCreationResult {
case created(GroupInfo, GroupLink, [GroupRelay])
case creationFailed([AddRelayResult])
}
func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> PublicGroupCreationResult? {
let userId = try currentUserId("apiNewPublicGroup")
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile))
switch r {
case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)):
return .created(groupInfo, groupLink, groupRelays)
case let .result(.publicGroupCreationFailed(_, addRelayResults)):
return .creationFailed(addRelayResults)
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId))
if case let .result(.groupRelays(_, _, relays)) = r { return relays }
return []
}
enum AddGroupRelaysResult {
case added(GroupInfo, GroupLink, [GroupRelay])
case addFailed([AddRelayResult])
}
func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds))
switch r {
case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)):
return .added(groupInfo, groupLink, groupRelays)
case let .result(.groupRelaysAddFailed(_, addRelayResults)):
return .addFailed(addRelayResults)
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
throw r.unexpected
}
enum JoinGroupResult {
case joined(groupInfo: GroupInfo)
case invitationRemoved
case groupNotFound
}
func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiJoinGroup(groupId: groupId))
switch r {
case let .result(.userAcceptedGroupSent(_, groupInfo, _)): return .joined(groupInfo: groupInfo)
case .error(.errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
case .error(.errorStore(.groupNotFound)): return .groupNotFound
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) {
let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole))
if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) }
throw r.unexpected
}
func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (GroupInfo, GroupMember) {
let r: ChatResponse2 = try await chatSendCmd(.apiDeleteMemberSupportChat(groupId: groupId, groupMemberId: groupMemberId))
if case let .memberSupportChatDeleted(_, groupInfo, member) = r { return (groupInfo, member) }
throw r.unexpected
}
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) {
let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) }
throw r.unexpected
}
func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] {
let r: ChatResponse2 = try await chatSendCmd(.apiMembersRole(groupId: groupId, memberIds: memberIds, memberRole: memberRole), bgTask: false)
if case let .membersRoleUser(_, _, members, _) = r { return members }
throw r.unexpected
}
func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] {
let r: ChatResponse2 = try await chatSendCmd(.apiBlockMembersForAll(groupId: groupId, memberIds: memberIds, blocked: blocked), bgTask: false)
if case let .membersBlockedForAllUser(_, _, members, _) = r { return members }
throw r.unexpected
}
func leaveGroup(_ groupId: Int64) async {
do {
let groupInfo = try await apiLeaveGroup(groupId)
DispatchQueue.main.async { ChatModel.shared.updateGroup(groupInfo) }
} catch let error {
logger.error("leaveGroup error: \(responseError(error))")
}
}
func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
let r: ChatResponse2 = try await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false)
if case let .leftMemberUser(_, groupInfo) = r { return groupInfo }
throw r.unexpected
}
// use ChatModel's loadGroupMembers from views
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiListMembers(groupId: groupId))
if case let .result(.groupMembers(_, group)) = r { return group.members }
return []
}
func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats
.compactMap{ c in c.chatInfo.sendMsgEnabled ? c.chatInfo.contact : nil }
.filter{ c in !c.sendMsgToConnect && !memberContactIds.contains(c.apiId) }
.sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() }
}
func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo {
let r: ChatResponse2 = try await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile))
if case let .groupUpdated(_, toGroup) = r { return toGroup }
throw r.unexpected
}
func apiSetPublicGroupAccess(_ groupId: Int64, access: PublicGroupAccess) async throws -> GroupInfo {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiSetPublicGroupAccess(groupId: groupId, access: access))
if case let .result(.groupUpdated(_, toGroup)) = r { return toGroup }
showSetSimplexNameError(r, isChannel: true)
throw r.unexpected
}
func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole))
if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink }
if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r {
showClientNotice(server, preset, expires)
return nil
}
if let r { throw r.unexpected } else { return nil }
}
func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink {
let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole))
if case let .groupLink(_, _, groupLink) = r { return groupLink }
throw r.unexpected
}
func apiDeleteGroupLink(_ groupId: Int64) async throws {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiDeleteGroupLink(groupId: groupId))
if case .result(.groupLinkDeleted) = r { return }
if let r { throw r.unexpected }
}
func apiGetGroupLink(_ groupId: Int64) throws -> GroupLink? {
let r: APIResult<ChatResponse2> = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId))
switch r {
case let .result(.groupLink(_, _, groupLink)):
return groupLink
case .error(.errorStore(storeError: .groupLinkNotFound)):
return nil
default: throw r.unexpected
}
}
func apiAddGroupShortLink(_ groupId: Int64) async throws -> GroupLink? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAddGroupShortLink(groupId: groupId))
if case let .result(.groupLink(_, _, groupLink)) = r { return groupLink }
if let r { throw r.unexpected } else { return nil }
}
func apiCreateMemberContact(_ groupId: Int64, _ groupMemberId: Int64) async throws -> Contact {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateMemberContact(groupId: groupId, groupMemberId: groupMemberId))
if case let .newMemberContact(_, contact, _, _) = r { return contact }
throw r.unexpected
}
func apiSendMemberContactInvitation(_ contactId: Int64, _ msg: MsgContent) async throws -> Contact {
let r: ChatResponse2 = try await chatSendCmd(.apiSendMemberContactInvitation(contactId: contactId, msg: msg), bgDelay: msgDelay)
if case let .newMemberContactSentInv(_, contact, _, _) = r { return contact }
throw r.unexpected
}
func apiAcceptMemberContact(contactId: Int64) async -> Contact? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAcceptMemberContact(contactId: contactId))
if case let .result(.memberContactAccepted(_, contact)) = r { return contact }
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
return nil
}
func acceptMemberContact(contactId: Int64, inProgress: Binding<Bool>? = nil) async {
await MainActor.run { inProgress?.wrappedValue = true }
if let contact = await apiAcceptMemberContact(contactId: contactId) {
await MainActor.run {
ChatModel.shared.updateContact(contact)
inProgress?.wrappedValue = false
}
if contact.sndReady {
DispatchQueue.main.async {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(contact.id)
}
}
}
} else {
await MainActor.run { inProgress?.wrappedValue = false }
}
}
func apiGetVersion() throws -> CoreVersionInfo {
let r: ChatResponse2 = try chatSendCmdSync(.showVersion)
if case let .versionInfo(info, _, _) = r { return info }
throw r.unexpected
}
func getAgentSubsTotal() async throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r: ChatResponse2 = try await chatSendCmd(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r.unexpected
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r: ChatResponse2 = try chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
if case let .agentServersSummary(_, serversSummary) = r { return serversSummary }
logger.error("getAgentServersSummary error: \(String(describing: r))")
throw r.unexpected
}
func resetAgentServersStats() async throws {
try await sendCommandOkResp(.resetAgentServersStats)
}
private func currentUserId(_ funcName: String) throws -> Int64 {
if let userId = ChatModel.shared.currentUser?.userId {
return userId
}
throw RuntimeError("\(funcName): no current user")
}
func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = nil, refreshInvitations: Bool = true, confirmMigrations: MigrationConfirmation? = nil) throws {
logger.debug("initializeChat")
let m = ChatModel.shared
m.ctrlInitInProgress = true
defer { m.ctrlInitInProgress = false }
(m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations)
if m.chatDbStatus != .ok { return }
NetworkObserver.shared.restartMonitor()
// If we migrated successfully means previous re-encryption process on database level finished successfully too
if encryptionStartedDefault.get() {
encryptionStartedDefault.set(false)
}
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true
m.currentUser = try apiGetActiveUser()
m.conditions = try getServerOperatorsSync()
if shouldImportAppSettingsDefault.get() {
do {
let appSettings = try apiGetAppSettings(settings: AppSettings.current.prepareForExport())
appSettings.importIntoApp()
shouldImportAppSettingsDefault.set(false)
} catch {
logger.error("Error while importing app settings: \(error)")
}
}
if m.currentUser == nil {
onboardingStageDefault.set(.step1_SimpleXInfo)
privacyDeliveryReceiptsSet.set(true)
m.onboardingStage = .step1_SimpleXInfo
} else if confirmStart {
showStartChatAfterRestartAlert { start in
do {
if start { AppChatState.shared.set(.active) }
try chatInitialized(start: start, refreshInvitations: refreshInvitations)
} catch let error {
logger.error("ChatInitialized error: \(error)")
}
}
} else {
try chatInitialized(start: start, refreshInvitations: refreshInvitations)
}
}
func showStartChatAfterRestartAlert(result: @escaping (_ start: Bool) -> Void) {
AlertManager.shared.showAlert(Alert(
title: Text("Start chat?"),
message: Text("Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat."),
primaryButton: .default(Text("Ok")) {
result(true)
},
secondaryButton: .cancel {
result(false)
}
))
}
private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
let m = ChatModel.shared
if m.currentUser == nil { return }
if start {
try startChat(refreshInvitations: refreshInvitations)
} else {
m.chatRunning = false
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
m.onboardingStage = onboardingStageDefault.get()
}
}
// Spec: spec/architecture.md#startChat
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
let chatRunning = try apiCheckChatRunning()
m.users = try listUsers()
if !chatRunning {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
Task { try await refreshCallInvitations() }
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
registerToken(token: token)
}
if !onboarding {
withAnimation {
let savedOnboardingStage = onboardingStageDefault.get()
m.onboardingStage = [.step1_SimpleXInfo, .step2_CreateProfile].contains(savedOnboardingStage) && m.users.count == 1
? .step4_NetworkCommitments
: savedOnboardingStage
if m.onboardingStage == .onboardingComplete && !privacyDeliveryReceiptsSet.get() {
m.setDeliveryReceipts = true
}
}
}
}
ChatReceiver.shared.start()
m.chatRunning = true
chatLastStartGroupDefault.set(Date.now)
}
func startChatWithTemporaryDatabase(ctrl: chat_ctrl) throws -> User? {
logger.debug("startChatWithTemporaryDatabase")
let migrationActiveUser = try? apiGetActiveUser(ctrl: ctrl) ?? apiCreateActiveUser(Profile(displayName: "Temp", fullName: ""), ctrl: ctrl)
try setNetworkConfig(getNetCfg(), ctrl: ctrl)
try apiSetAppFilePaths(filesFolder: getMigrationTempFilesDirectory().path, tempFolder: getMigrationTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path, ctrl: ctrl)
_ = try apiStartChat(ctrl: ctrl)
return migrationActiveUser
}
func changeActiveUser(_ userId: Int64, viewPwd: String?) {
do {
try changeActiveUser_(userId, viewPwd: viewPwd)
} catch let error {
logger.error("Unable to set active user: \(responseError(error))")
}
}
private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws {
let m = ChatModel.shared
m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd)
m.users = try listUsers()
try getUserChatData()
}
func changeActiveUserAsync_(_ userId: Int64?, viewPwd: String?, keepingChatId: String? = nil) async throws {
let currentUser = if let userId = userId {
try await apiSetActiveUserAsync(userId, viewPwd: viewPwd)
} else {
try apiGetActiveUser()
}
let users = try await listUsersAsync()
await MainActor.run {
let m = ChatModel.shared
m.currentUser = currentUser
m.users = users
}
try await getUserChatDataAsync(keepingChatId: keepingChatId)
await MainActor.run {
if let currentUser = currentUser, var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) {
invitation.user = currentUser
activateCall(invitation)
}
}
}
func getUserChatData() throws {
let m = ChatModel.shared
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
let tags = try apiGetChatTags()
m.updateChats(chats)
let tm = ChatTagsModel.shared
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
}
private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if m.currentUser != nil {
let userAddress = try await apiGetUserAddressAsync()
let chatItemTTL = try await getChatItemTTLAsync()
let chats = try await apiGetChatsAsync()
let tags = try await apiGetChatTagsAsync()
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
m.updateChats(chats, keepingChatId: keepingChatId)
tm.activeFilter = nil
tm.userTags = tags
tm.updateChatTags(m.chats)
}
} else {
await MainActor.run {
m.userAddress = nil
m.updateChats([])
tm.activeFilter = nil
tm.userTags = []
tm.presetTags = [:]
}
}
}
// Spec: spec/architecture.md#ChatReceiver
class ChatReceiver {
private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true
private var _lastMsgTime = Date.now
var messagesChannel: ((APIResult<ChatEvent>) -> Void)? = nil
static let shared = ChatReceiver()
var lastMsgTime: Date { get { _lastMsgTime } }
func start() {
logger.debug("ChatReceiver.start")
receiveMessages = true
_lastMsgTime = .now
if receiveLoop != nil { return }
receiveLoop = Task { await receiveMsgLoop() }
}
func receiveMsgLoop() async {
while self.receiveMessages {
if let msg = await chatRecvMsg() {
self._lastMsgTime = .now
Task { await TerminalItems.shared.addResult(msg) }
switch msg {
case let .result(evt): await processReceivedMsg(evt)
case let .error(err): logger.debug("chatRecvMsg error: \(responseError(err))")
case let .invalid(type, json): logger.debug("chatRecvMsg event: * \(type) \(dataToString(json))")
}
if let messagesChannel {
messagesChannel(msg)
}
}
_ = try? await Task.sleep(nanoseconds: 7_500_000)
}
}
func stop() {
logger.debug("ChatReceiver.stop")
receiveMessages = false
receiveLoop?.cancel()
receiveLoop = nil
}
}
// Spec: spec/api.md#processReceivedMsg
func processReceivedMsg(_ res: ChatEvent) async {
let m = ChatModel.shared
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
}
}
case let .contactConnected(user, contact, _):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
if contact.id == m.chatId, let conn = contact.activeConn {
m.chatAgentConnId = conn.agentConnId
m.chatSubStatus = .active
}
}
}
if contact.directOrUsed {
NtfManager.shared.notifyContactConnected(user, contact)
}
case let .contactConnecting(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
case let .receivedContactRequest(user, contactRequest, chat_):
if active(user) {
await MainActor.run {
if let chat = chat_ { // means contact request was created with contact, so we need to add/update contact chat
if !m.hasChat(chat.id) {
m.addChat(Chat(chat))
} else if m.chatId == chat.id {
m.updateChatInfo(chat.chatInfo)
} else {
m.replaceChat(chat.id, Chat(chat))
}
} else {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
} else {
m.addChat(Chat(
chatInfo: cInfo,
chatItems: []
))
}
}
}
}
NtfManager.shared.notifyContactRequest(user, contactRequest)
case let .contactUpdated(user, toContact):
if active(user) && m.hasChat(toContact.id) {
await MainActor.run {
let cInfo = ChatInfo.direct(contact: toContact)
m.updateChatInfo(cInfo)
}
}
case let .groupMemberUpdated(user, groupInfo, _, toMember):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, toMember)
}
}
case let .subscriptionStatus(status, connections):
if let chatAgentConnId = m.chatAgentConnId, connections.contains(chatAgentConnId) {
await MainActor.run {
m.chatSubStatus = status
}
}
case let .chatInfoUpdated(user, chatInfo):
if active(user) {
await MainActor.run {
m.updateChatInfo(chatInfo)
}
}
case let .newChatItems(user, chatItems):
for chatItem in chatItems {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
} else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) {
m.increaseUnreadCounter(user: user)
}
}
if let file = cItem.autoReceiveFile() {
Task {
await receiveFile(user: user, fileId: file.fileId, auto: true)
}
}
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
case let .chatItemsStatusesUpdated(user, chatItems):
for chatItem in chatItems {
let cInfo = chatItem.chatInfo
let cItem = chatItem.chatItem
if !cItem.isDeletedContent && active(user) {
_ = await MainActor.run { m.upsertChatItem(cInfo, cItem) }
}
if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndNew: ()
case .sndSent: endTask()
case .sndRcvd: endTask()
case .sndErrorAuth: endTask()
case .sndError: endTask()
case .sndWarning: endTask()
case .rcvNew: ()
case .rcvRead: ()
case .invalid: ()
}
}
}
case let .chatItemUpdated(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .chatItemReaction(user, _, r):
if active(user) {
await MainActor.run {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
}
case let .chatItemsDeleted(user, items, _):
if !active(user) {
for item in items {
let d = item.deletedChatItem
if item.toChatItem == nil && d.chatItem.isRcvNew && d.chatInfo.ntfsEnabled(chatItem: d.chatItem) {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
}
}
return
}
await MainActor.run {
for item in items {
if let toChatItem = item.toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
if item.deletedChatItem.chatItem.isActiveReport {
m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id)
}
}
if let updatedChatInfo = items.last?.deletedChatItem.chatInfo {
m.updateChatInfo(updatedChatInfo)
}
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated
// NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation?
}
}
case let .userAcceptedGroupSent(user, groupInfo, hostContact):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
if let conn = hostContact?.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, hostMember)
if let hostConn = hostMember.activeConn {
m.dismissConnReqView(hostConn.id)
m.removeChat(hostConn.id)
}
}
case let .businessLinkConnecting(user, groupInfo, _, fromContact):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
}
if m.chatId == fromContact.id {
ItemsModel.shared.loadOpenChat(groupInfo.id)
}
await MainActor.run {
m.removeChat(fromContact.id)
}
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .memberAcceptedByOther(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
m.updateGroup(groupInfo)
}
}
case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
if withMessages {
m.removeMemberItems(groupInfo.membership, byMember: member, groupInfo)
}
}
}
case let .deletedMember(user, groupInfo, byMember, deletedMember, withMessages):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, deletedMember)
if withMessages {
m.removeMemberItems(deletedMember, byMember: byMember, groupInfo)
}
}
}
case let .leftMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .groupDeleted(user, groupInfo, _): // TODO update user member
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
}
}
case let .userJoinedGroup(user, groupInfo, hostMember):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, hostMember)
}
if m.chatId == groupInfo.id {
if groupInfo.membership.memberPending {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = true
}
} else if case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = false
}
}
}
}
case let .joinedGroupMember(user, groupInfo, member):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .connectedToGroupMember(user, groupInfo, member, memberContact):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .groupUpdated(user, toGroup):
if active(user) {
await MainActor.run {
m.updateGroup(toGroup)
}
}
case let .groupLinkDataUpdated(user, groupInfo, _, groupRelays, _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
let relaysModel = ChannelRelaysModel.shared
if relaysModel.groupId == groupInfo.groupId {
relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays)
}
}
}
case let .groupRelayUpdated(user, groupInfo, member, groupRelay):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, member)
ChannelRelaysModel.shared.updateRelay(groupInfo, groupRelay)
}
}
case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .memberBlockedForAll(user, groupInfo, byMember: _, member: member, blocked: _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .newMemberContactReceivedInv(user, contact, _, _):
if active(user) {
await MainActor.run {
m.updateContact(contact)
}
}
case let .rcvFileAccepted(user, aChatItem): // usually rcvFileAccepted is a response, but it's also an event for XFTP files auto-accepted from NSE
await chatItemSimpleUpdate(user, aChatItem)
// TODO when aChatItem added
// case let .rcvFileAcceptedSndCancelled(user, aChatItem, _): // usually rcvFileAcceptedSndCancelled is a response, but it's also an event for legacy files auto-accepted from NSE.
// await chatItemSimpleUpdate(user, aChatItem)
// Task { cleanupFile(aChatItem) }
case let .rcvFileStart(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileComplete(user, aChatItem):
await chatItemSimpleUpdate(user, aChatItem)
case let .rcvFileSndCancelled(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
case let .rcvFileProgressXFTP(user, aChatItem, _, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .rcvFileError(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
case let .rcvFileWarning(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .sndFileStart(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileComplete(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) }
case let .sndFileRcvCancelled(user, aChatItem, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupDirectFile(aChatItem) }
}
case let .sndFileProgressXFTP(user, aChatItem, _, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .sndFileCompleteXFTP(user, aChatItem, _):
await chatItemSimpleUpdate(user, aChatItem)
case let .sndFileError(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
Task { cleanupFile(aChatItem) }
}
case let .sndFileWarning(user, aChatItem, _, _):
if let aChatItem = aChatItem {
await chatItemSimpleUpdate(user, aChatItem)
}
case let .callInvitation(invitation):
await MainActor.run {
m.callInvitations[invitation.contact.id] = invitation
}
activateCall(invitation)
case let .callOffer(_, contact, callType, offer, sharedKey, _):
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
call.sharedKey = sharedKey
}
let useRelay = UserDefaults.standard.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
let iceServers = getIceServers()
logger.debug(".callOffer useRelay \(useRelay)")
logger.debug(".callOffer iceServers \(String(describing: iceServers))")
await m.callCommand.processCommand(.offer(
offer: offer.rtcSession,
iceCandidates: offer.rtcIceCandidates,
media: callType.media, aesKey: sharedKey,
iceServers: iceServers,
relay: useRelay
))
}
case let .callAnswer(_, contact, answer):
await withCall(contact) { call in
await MainActor.run {
call.callState = .answerReceived
}
await m.callCommand.processCommand(.answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates))
}
case let .callExtraInfo(_, contact, extraInfo):
await withCall(contact) { _ in
await m.callCommand.processCommand(.ice(iceCandidates: extraInfo.rtcIceCandidates))
}
case let .callEnded(_, contact):
if let invitation = await MainActor.run(body: { m.callInvitations.removeValue(forKey: contact.id) }) {
CallController.shared.reportCallRemoteEnded(invitation: invitation)
}
await withCall(contact) { call in
await m.callCommand.processCommand(.end)
CallController.shared.reportCallRemoteEnded(call: call)
}
case .chatSuspended:
chatSuspended()
case let .contactSwitch(user, contact, switchProgress):
if active(user) {
await MainActor.run {
m.updateContactConnectionStats(contact, switchProgress.connectionStats)
}
}
case let .groupMemberSwitch(user, groupInfo, member, switchProgress):
if active(user) {
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, switchProgress.connectionStats)
}
}
case let .contactRatchetSync(user, contact, ratchetSyncProgress):
if active(user) {
await MainActor.run {
m.updateContactConnectionStats(contact, ratchetSyncProgress.connectionStats)
}
}
case let .groupMemberRatchetSync(user, groupInfo, member, ratchetSyncProgress):
if active(user) {
await MainActor.run {
m.updateGroupMemberConnectionStats(groupInfo, member, ratchetSyncProgress.connectionStats)
}
}
case let .contactDisabled(user, contact):
if active(user) {
await MainActor.run {
m.updateContact(contact)
}
}
case let .remoteCtrlFound(remoteCtrl, ctrlAppInfo_, appVersion, compatible):
await MainActor.run {
if let sess = m.remoteCtrlSession, case .searching = sess.sessionState {
let state = UIRemoteCtrlSessionState.found(remoteCtrl: remoteCtrl, compatible: compatible)
m.remoteCtrlSession = RemoteCtrlSession(
ctrlAppInfo: ctrlAppInfo_,
appVersion: appVersion,
sessionState: state
)
}
}
case let .remoteCtrlSessionCode(remoteCtrl_, sessionCode):
await MainActor.run {
let state = UIRemoteCtrlSessionState.pendingConfirmation(remoteCtrl_: remoteCtrl_, sessionCode: sessionCode)
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
}
case let .remoteCtrlConnected(remoteCtrl):
// TODO currently it is returned in response to command, so it is redundant
await MainActor.run {
let state = UIRemoteCtrlSessionState.connected(remoteCtrl: remoteCtrl, sessionCode: m.remoteCtrlSession?.sessionCode ?? "")
m.remoteCtrlSession = m.remoteCtrlSession?.updateState(state)
}
case let .remoteCtrlStopped(_, rcStopReason):
// This delay is needed to cancel the session that fails on network failure,
// e.g. when user did not grant permission to access local network yet.
if let sess = m.remoteCtrlSession {
await MainActor.run {
m.remoteCtrlSession = nil
dismissAllSheets() {
switch rcStopReason {
case .disconnected:
()
case .connectionFailed(.errorAgent(.RCP(.identity))):
AlertManager.shared.showAlertMsg(
title: "Connection with desktop stopped",
message: "This link was used with another mobile device, please create a new link on the desktop."
)
default:
AlertManager.shared.showAlert(Alert(
title: Text("Connection with desktop stopped"),
message: Text("Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers."),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("Copy error")) { UIPasteboard.general.string = String(describing: rcStopReason) }
))
}
}
}
if case .connected = sess.sessionState {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
switchToLocalSession()
}
}
}
case let .contactPQEnabled(user, contact, _):
if active(user) {
await MainActor.run {
m.updateContact(contact)
}
}
default:
logger.debug("unsupported event: \(res.responseType)")
}
func withCall(_ contact: Contact, _ perform: (Call) async -> Void) async {
if let call = m.activeCall, call.contact.apiId == contact.apiId {
await perform(call)
} else {
logger.debug("processReceivedMsg: ignoring \(res.responseType), not in call with the contact \(contact.id)")
}
}
}
func switchToLocalSession() {
let m = ChatModel.shared
m.remoteCtrlSession = nil
do {
m.users = try listUsers()
try getUserChatData()
} catch let error {
logger.debug("error updating chat data: \(responseError(error))")
}
}
func active(_ user: any UserLike) -> Bool {
user.userId == ChatModel.shared.currentUser?.id
}
func chatItemSimpleUpdate(_ user: any UserLike, _ aChatItem: AChatItem) async {
let m = ChatModel.shared
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if active(user) {
if await MainActor.run(body: { m.upsertChatItem(cInfo, cItem) }) {
if cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
}
}
}
func groupChatItemsDeleted(_ user: UserRef, _ groupInfo: GroupInfo, _ chatItemIDs: Set<Int64>, _ member_: GroupMember?) async {
let m = ChatModel.shared
if !active(user) {
do {
let users = try listUsers()
await MainActor.run {
m.users = users
}
} catch {
logger.error("Error loading users: \(error)")
}
return
}
let im = ItemsModel.shared
let cInfo = ChatInfo.group(groupInfo: groupInfo, groupChatScope: nil)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}
var notFound = chatItemIDs.count
for ci in im.reversedChatItems {
if chatItemIDs.contains(ci.id) {
let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId {
CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_)
} else {
CIDeleted.deleted(deletedTs: Date.now)
}
await MainActor.run {
var newItem = ci
newItem.meta.itemDeleted = deleted
_ = m.upsertChatItem(cInfo, newItem)
}
notFound -= 1
if notFound == 0 { break }
}
}
}
func refreshCallInvitations() async throws {
let m = ChatModel.shared
let callInvitations = try await apiGetCallInvitations()
await MainActor.run {
m.callInvitations = callsByChat(callInvitations)
if let (chatId, ntfAction) = m.ntfCallInvitationAction,
let invitation = m.callInvitations.removeValue(forKey: chatId) {
m.ntfCallInvitationAction = nil
CallController.shared.callAction(invitation: invitation, action: ntfAction)
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation)
}
}
}
func justRefreshCallInvitations() async throws {
let callInvitations = try apiGetCallInvitationsSync()
await MainActor.run {
ChatModel.shared.callInvitations = callsByChat(callInvitations)
}
}
private func callsByChat(_ callInvitations: [RcvCallInvitation]) -> [ChatId: RcvCallInvitation] {
callInvitations.reduce(into: [ChatId: RcvCallInvitation]()) {
result, inv in result[inv.contact.id] = inv
}
}
func activateCall(_ callInvitation: RcvCallInvitation) {
let m = ChatModel.shared
logger.debug("reportNewIncomingCall activeCallUUID \(String(describing: m.activeCall?.callUUID)) invitationUUID \(String(describing: callInvitation.callUUID))")
if !callInvitation.user.showNotifications || m.activeCall?.callUUID == callInvitation.callUUID { return }
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {
DispatchQueue.main.async {
m.callInvitations[callInvitation.contact.id]?.callUUID = nil
}
logger.error("reportNewIncomingCall error: \(error.localizedDescription)")
} else {
logger.debug("reportNewIncomingCall success")
}
}
}
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
private func showClientNotice(_ server: String, _ preset: Bool, _ expiresAt: Date?) {
DispatchQueue.main.async {
var message = "Server: \(server).\nConditions of use violation notice received from \(preset ? "preset" : "this") server.\nNo IDs shared, see How it works."
if let expiresAt {
message += "\n\nNew addresses can be created after \(expiresAt.formatted(date: .abbreviated, time: .shortened))."
}
showAlert("Not allowed", message: message) {
let howItWorks = UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
UIApplication.shared.open(contentModerationPostLink)
})
return preset
? [
okAlertAction,
UIAlertAction(title: NSLocalizedString("Conditions of use", comment: "alert button"), style: .default, handler: { _ in
UIApplication.shared.open(conditionsURL)
}),
howItWorks
]
: [okAlertAction, howItWorks]
}
}
}