Files
simplex-chat/apps/ios/Shared/Model/SimpleXAPI.swift
T
spaced4ndyandspacedandy-agents-simplex 9696a71b91 core, badge-service: renew supporter badges from App Store and Google Play subscriptions (#7716)
* core, badge-service: name Apple claims by reference

Rename appleTransactionId to appleTransactionRef, matching googlePurchaseRef
and StoreTransactionRef. No behaviour change: the next change makes it
return a subscription's original transaction id, which the old name would
misstate.

Run: 001-badge-subscriptions, slice 1

* core, badge-service: store subscription charges and carry the subscription state

Add the service migration 20261012_badge_subscriptions: the subscription
charges table, the ledger's charge_id, and the payment's subscription state,
check time and Play token, replacing three subscription columns nothing
wrote. The ledger now stores and reads back charge credits, and the badge
credential response gains an optional subscription state that older clients
ignore and that reads an unknown state without failing. Nothing writes a
charge or a state yet, so no behaviour changes.

Run: 001-badge-subscriptions, slice 2

* core, badge-service: plan subscription charges and claim Apple subscriptions by original id

Add chargeEntries, the one planner for a store charge's ledger rows: a
lapse to the billing period start, the grant dated to that start, and a
lapse to now, so coverage follows the store's periods and every row
verifies by re-running it at its createdAt. A test runs its rows through
the client's own balanceChecked for first charges, monthly and annual
renewals, billing-retry recovery and grace.

An Apple auto-renewable transaction is now claimed by its original
transaction id, which every renewal keeps, so renewals resolve to one
purchase. One function names the ref for both the claim and the real
verifier, so they cannot disagree.

Run: 001-badge-subscriptions, slice 3a

* badge-service: credit a store subscription purchase

The catalog now tells subscription products from one-time ones and maps a
store plan to its tier and months. A subscription receipt is checked with
the store once: with a grantable charge, the service plans the charge's
rows from the store's period start, signs the first month, and writes the
payment, purchase, charge, ledger rows, issuance, state and Play token in
one transaction, answering with the subscription state. Without one, it
answers payment_pending (300 s while the period has not started or the
store is pending, a day otherwise), receipt_invalid for an ended Play
subscription, or test_purchase, and writes nothing. Replays carry the
state. The store verifier interface gains the subscription check, which no
real store implements yet; the fake store does.

Run: 001-badge-subscriptions, slice 3b

* badge-service: renew a subscription once its paid coverage ends

While a subscription purchase's coverage runs, issueBadge is unchanged
and makes no store call. Once it has ended, the service asks the store at
most once an hour per subscription, stamping every attempt, and answers
from the stored state in between; a failed check is retried after the
whole interval. A grantable charge is planned from the tip at the store's
period start, signed, and written with its issuance and state in one
transaction that re-reads the tip, answering internal if it moved. Any
other charge and the state are recorded without a ledger row. Every answer
for a subscription purchase carries its state.

Run: 001-badge-subscriptions, slice 4

* core: keep a subscription badge until the service says it ended

A purchase is a subscription exactly when its ledger replica holds a
charge credit, so the kind is derived with no column or migration. A
subscription is still asked about at zero months, at the shared weekly
moment, and keeps its badge past paidThrough until an answer says the
subscription ended, so a renewed month is presented at the old
credential's expiry with no gap, and a charge the store recovers later
still re-activates it. While it waits, the client records no failure and
raises no support-ended alert; "issued no credential" now fires only while
the ledger still owes a month. Prepaid purchases are unchanged.

Run: 001-badge-subscriptions, slice 5

* badge-service: check Apple subscriptions with the App Store Server API

With an in-app purchase key configured in [apple], the service reads a
subscription's status by its original transaction id from Get All
Subscription Statuses, authenticated by an ES256 token reused until near
expiry. Each signed transaction and renewal info is verified against the
pinned Apple root through the same chain check as one-time purchases, now a
separate function, and a body that fails it is a verifier failure, never a
refusal. Statuses map to renewing, cancelled, billing retry and ended;
family-shared transactions are refused; 401 and 403 are this service's
failure, other errors and timeouts unreachable. A verifier failure thrown
by a store check is answered internal, and a test subscription is refused
in one place for first purchases and renewals. Without the key, Apple
one-time purchases verify as before and subscriptions are not configured.

Run: 001-badge-subscriptions, slice 6

* badge-service: check and acknowledge Google Play subscriptions

The Play subscription check reads purchases.subscriptionsv2 by token
through the existing service-account client, with the same HTTP classes as
product reads, and maps Play's state, base plan, period, order id and
offer phase; the period start is its end minus the plan's length, and a
pending subscription stays pending. A subscription is acknowledged with
purchases.subscriptions.acknowledge before it is credited, skipped when
Play reports it acknowledged, so it never reaches the one-time endpoint.
Each store's subscriptions answer provider_not_configured when that store
is not configured. A charge on a plan the catalog does not sell is pending
rather than a malformed period.

Run: 001-badge-subscriptions, slice 7

* review: round 1 fixes

R1-1: test an App Store subscription renewal through issueBadge, including
a charge recorded before its period starts and granted later under the
same charge id.
R1-2: make the ledger's charge_id index unique, so the schema itself grants
each store charge at most once.
R1-3: pin that a charge on a plan the catalog does not sell, with an empty
period, is pending at purchase and recorded at renewal, never internal.

Run: 001-badge-subscriptions, review round 1

* core: update query plans

getBadgePurchase derives the purchase kind from the ledger replica, and a
subscription test makes a held store receipt due.

Run: 001-badge-subscriptions, final verification

* fixes

* core: move SubscriptionState to Badges.Types (structural)

BadgeState, which lives in Badges.Types, is about to carry a subscription's
state, and Badges.Service imports Badges.Types. Service re-exports it, as it
does BadgeServiceErrorCode. No behaviour change.

Run: 003-badge-subscriptions-ui, slice 1

* core: keep a store subscription's state on the client

The screens and alerts need to know whether a subscription renews, was
cancelled, waits for a charge, or ended, and the client dropped every state
the service sent but ended. It is now stored on the purchase: from the
answer to the first credit, and from a renewal answer only when the
asserted tip's coverage had ended, since while coverage runs the service
repeats what it read at the last renewal (a year old for an annual plan).

BadgeState gains `subscription` (the plan's months, from the latest charge
credit, and the state), and fills renewsAt/willRenew from it. A changed
state is announced with CEvtBadgeChanged. The column goes into the
unreleased M20260925 migration.

Run: 003-badge-subscriptions-ui, slice 2

* core: record the device store's view of a subscription

The service reads the store only once a subscription's paid coverage has
ended, so it never sees a cancellation while the period runs. The apps read
the device's store (StoreKit's renewal state, Play's isAutoRenewing) and
report it with /_badge subscription <state> <payment>, which records it on
the credited subscription purchase the payment resolves to and announces the
change to its owner.

A renewing report does not replace billingRetry or ended: Play lists a
subscription in its grace period with auto-renew on, and the service's next
answer clears billing retry once the charge lands.

Run: 003-badge-subscriptions-ui, slice 3

* core: claim a store-listed transaction only on the device that bought it

A store account's purchases are listed on every device signed into it:
Play's purchase query lists active subscriptions for their whole life, and
Apple's Transaction.updates delivers auto-renewables made on other devices.
A hand-over with no record and an unknown echoed invoice made a new record
under the presenting profile, so another device could claim a buyer's
subscription for its own active profile, or repeat a refused request every
30 days after cleanup.

/_badge purchase now takes a `listed` mark, which the apps' sweeps and
purchase listeners send: it joins only a record this core already has for
the transaction or its echoed invoice, and otherwise records and sends
nothing (CRStoreReceiptNotRecorded). The purchase call and Restore hand over
unmarked, as before.

Run: 003-badge-subscriptions-ui, slice 4

* core: let a held App Store subscription adopt its later transactions

A held Apple subscription record is keyed by its original transaction id,
so every renewal resolves to it. It kept the payment's back-off when a new
transaction arrived, and adopted any payment handed over, including a
revoked renewal, which the service refuses as receipt_invalid and so would
refuse the whole subscription.

A held Apple record now adopts a JWS only for another transaction with a
later purchaseDate and no revocationDate, read from the unverified payload,
and is then due at once with its retry delay and credit error cleared. A
re-delivered or re-signed copy changes nothing. A failed attempt writes its
back-off only if the record still holds the payment it sent.

Run: 003-badge-subscriptions-ui, slice 5

* core: refuse a badge code or invoice while a store receipt is pending

A code redeemed while a store receipt is held put the code's badge on the
profile, and the held receipt then waited behind it; for a subscription the
store kept charging every period meanwhile. Code redemption and invoice
creation now answer "store purchase open" while the profile holds an App
Store subscription receipt, or another receipt whose last failure can clear
on its own. A receipt that failed for good does not block, except an Apple
subscription, which Apple keeps charging; an uncredited Play subscription is
refunded by Play.

Run: 003-badge-subscriptions-ui, slice 6

* core: alert a subscriber that a payment failed, a renewal nears, or it was cancelled or ended

A subscription's alerts were the prepaid ones: a retired subscription said
"support ended", and nothing told the user a payment failed, an annual
renewal was near, or a cancelled subscription still runs. The derived alert
for a subscription is now, in order: subscription_ended once retired,
payment_issue while the store retries, issue_failed as before,
subscription_cancelled before paidThrough, and renewal_approaching from
three days before an annual plan renews, with a wake at that moment. Monthly
plans get no reminder, as there is no opt-out yet.

The unreleased migration's down step clears an acked subscription_cancelled,
which an older core cannot read.

Run: 003-badge-subscriptions-ui, slice 7

* badge-service: let the dev mock vouch for store subscriptions

With [dev] accept_unverified_store_receipts on, every subscription was
answered provider_not_configured, so the apps' subscription screens could
not be tried against a dev service. The mock now credits an App Store
subscription for the plan its product names, and a Play subscription for
the base plan a test token test.<basePlanId>.<uuid> names, or else its
product's monthly plan, from a minute ago; it remembers the plan in memory
and grants each renewal a new charge, answering a subscription it does not
know (after a restart) as ended.

The subscription check is given the product the presented receipt names,
which only the mock reads; it reads the service's badge clock at check time.

Run: 003-badge-subscriptions-ui, slice 8

* ios: hand App Store subscriptions to the core and report their renewal state

A subscription transaction was finished uncredited, so a buyer would pay and
get nothing. Every badge product now goes through a core invoice and is
handed over, and finished only once the core answers it credited or refused.
Transactions the store lists (the launch sweep, Transaction.updates) are
handed over as listed, so another device's purchase is not claimed here;
Restore purchases syncs with the App Store and claims them for this profile.
A revoked renewal is finished without hand-over, as the service would refuse
it and so the whole subscription.

The app reports StoreKit's renewal state to the core at each sweep, after the
manage-subscriptions sheet and after a restore. Monthly and annual are offered
only to an Apple ID with no earlier subscription in the group, which Apple
would fold a new one into.

Run: 003-badge-subscriptions-ui, slice 9

* ios: sell badge subscriptions and show their state on Your badge

Monthly and annual subscriptions are offered on How long and Check your
order, with the App Store's subscription disclosure, Terms of use and
Privacy policy links, and Restore purchases. Your badge shows a
subscription's state - Renews {date} · {price}, Ends {date}, Payment
problem, Renewing or Ended - with why another badge cannot be bought or
redeemed while it waits for its charge, Manage subscription and Restore
purchases. The chat list shows one banner for the current badge alert of
any kind: renewal soon, payment problem, cancelled, ended, as well as the
expired and renewal-failed ones it had.

Run: 003-badge-subscriptions-ui, slice 10

* android: hand Play subscriptions to the core and report their renewal state

The Kotlin port of the iOS store change. A Play subscription was
acknowledged uncredited; every badge product now goes through a core
invoice and is handed over. The sweep queries subscriptions as well as
one-time products, and the sweep and purchases listener hand over as listed,
so another device on the Google account does not claim them; Restore claims
them for this profile. A subscription is bought on its base plan's own
offer, and the app never acknowledges one itself: the service does when it
credits it, so Play still refunds one the service never credits.

The app reports Play's isAutoRenewing to the core as renewing or cancelled.

Run: 003-badge-subscriptions-ui, slice 11

* android, desktop: sell badge subscriptions and show their state on Your badge

The Kotlin port of the iOS screens. How long and Check your order offer
monthly and annual with Google Play's subscription disclosure, the privacy
policy link and Restore purchases. Your badge shows the subscription's state
with the same texts as iOS, the store named Google Play with a store lane and
"the store" without one, and Manage subscription and Restore purchases only
with a store lane on the local host. The chat list shows one banner for the
current badge alert of any kind. Desktop shows the state, footers and banners
without store rows or a price.

Run: 003-badge-subscriptions-ui, slice 12

* docs: describe the one badge alert banner in the apps' chat-list specs

Both chat-list specs described two hand-written badge banners (expired,
renewal failed); the chat list now has one banner for the current badge
alert of any kind. Also moves the line links this run shifted.

Run: 003-badge-subscriptions-ui, slice 13

* review: round 1 fixes

ios: stop selling a subscription once this device has seen one, so a
second profile is not charged for a subscription Apple folds into the
first (R1-12). Continue pins the chosen period on both apps (R1-13).
Only a device with a subscription asks its store for products and status
when reporting, and the purchases are marked checked before the report
(R1-14, R1-5: Kotlin now loads the products, so the price shows after a
restart). Restore says "Nothing to restore" after a cancelled purchase
(R1-7). Android keeps sweeping one-time purchases when the subscriptions
query fails (R1-6). The freshness comment in requestBadgeIssue states the
service's one-hour repeat of its last check (R1-1).

Run: 003-badge-subscriptions-ui, review round 1

* badge-service: answer a long-expired Play subscription as ended, and hash Play order ids

Play answers HTTP 410 for a subscription that expired too long ago to be
queried; it was read as unreachable, so the service never answered ended,
the badge was never retired and the profile stayed blocked from buying or
redeeming (R1-2). Play order ids appear on the buyer's receipt email, so
charges now store a hash of the order id, as purchase tokens already are
(R1-10).

Run: 004-badge-subscriptions-review, review round 1

* review: run 003 round 2 and run 004 round 1 app fixes

ios: buy a subscription only while it is still for sale, after re-reading
the Apple ID's transactions, and disable Pay once it is not, so a Check
your order screen opened before another profile or device subscribed
cannot buy one Apple folds into the earlier subscription (run 003 R2-1,
run 004 R1-4). android: skip the subscriptions query only on a device
whose Play does not support subscriptions, so Restore no longer says
"Nothing to restore" when the query failed (run 003 R2-2).

Run: 003-badge-subscriptions-ui round 2; 004-badge-subscriptions-review round 1

* review: round 3 fixes

ios: a subscription refused because this Apple ID subscribed before has
its own error, and Check your order says why Pay is disabled (R3-2).
android: skip the subscriptions query only when Play does not support
subscriptions, so a disconnected client fails the sweep or Restore
rather than reading as no subscriptions (R3-1).

Run: 003-badge-subscriptions-ui, review round 3

* core: update query plans

Regenerated by the full suite for the purchase, subscription status and adoption queries. Run: 003-badge-subscriptions-ui, final step

* badge-service: say what hashing the Play order id protects

The hash is unkeyed, so a holder of the buyer's receipt can still find the row (R2-1). Run: 004-badge-subscriptions-review, review round 2

* badge-service: name the rule for when a subscription is due a store check

The issue path's purchase read becomes getIssuePurchase and its "ask the
store now" test becomes subscriptionCheckDue, so the request router can
read the same rows by the same rule. No behaviour change.

Run: 005-subscriptions-load-timing, slice 1

* badge-service: answer every request that may ask a store on the store lane

App Store subscription purchases and subscription renewal checks called
their store from the main lane, so one slow or repeated store call held up
code redemptions and every other renewal. The router now reads the
purchase the way the issue path does and sends to the store lane every
request that may ask a store: Play purchases, App Store auto-renewable
purchases, and issue requests for a subscription due its store check,
judged a minute ahead so the handler never finds one due on the main lane.

Run: 005-subscriptions-load-timing, slice 2

* core: send no badge request before the wake a request that issued nothing set

While a subscription waited for its store charge, or a prepaid renewal
kept failing, every launch, foreground, profile switch and badge-state
read sent the issue request again, so the badge service saw the user's
app-open times for weeks. A due request now waits for the wake the last
pass stored, as long as that wake is no more than a stall interval away,
so it is asked again a day later as the renewal design says.

Run: 005-subscriptions-load-timing, slice 3

* fixup! core: send no badge request before the wake a request that issued nothing set

* fixup! badge-service: answer every request that may ask a store on the store lane

* badge-service: ask the App Store about a subscription purchase at most once an hour per claim

Anyone holding a real but uncreditable App Store subscription JWS, such as
their own lapsed subscription, could present it again and again, each time
spending an App Store Server API call from the quota every subscriber's
renewal check needs. The purchase path now remembers, in memory, which
subscription claims it asked about within the hour and answers a repeat
payment_pending with the time left. Keys are original transaction ids from
JWS that verified offline, entries expire after the hour, and a full table
asks rather than refuses, so memory stays bounded and no buyer waits on
another's claim.

Run: 005-subscriptions-load-timing, slice 4

* fixup! badge-service: ask the App Store about a subscription purchase at most once an hour per claim

* review: round 1 fixes

badge-service: a Play charge whose clipped period start leaves its month
already spent is answered as period over, not internal (R1-A1).
docs: the per-store answer classes list Play's 410 and the App Store
subscription check (R1-A2).
tests: a held receipt with no attempt yet blocks an invoice (R1-B2); a
renewing report keeps an ended status (R1-B3); a test comment names
[google] (run 005 C2-R2-1).
ios: a subscription refused because this Apple ID subscribed before
shows its sentence, not the error's name (R1-C2); ios, android, desktop:
the Check your order footnotes share one helper (R1-C3).

Run: 006-subscriptions-branch-review, review round 1

* fixup! review: round 1 fixes

* core: wait a day after a badge request that issued nothing, whatever the service or a snooze says

The hold on the stored wake let two moments the client does not schedule end it early. A renewal whose store check failed was answered with an hour's retryAfter, so the next activation after each hour sent it again, sampling when the app is opened. A snooze set before a request ended its wait at the snooze's end, timing the next request by the user's tap. Now a request that issued nothing waits the stall or a ledger moment: retryAfter only marks the failure transient, and a snooze is not a wake while a request waits; the request's own wake raises the alert again.

Run: 005-subscriptions-load-timing, slice 5

* fixup! review: round 1 fixes

* fixup! core: wait a day after a badge request that issued nothing, whatever the service or a snooze says

* badge-service: ask Play about a subscription purchase at most once an hour per token

The purchase path's hourly bound covered only App Store claims, so a re-presented Play subscription token, uncredited or credited to another key, spent a subscriptionsv2.get call each time, from the quota bucket Play renewal checks use. The bound now lives in purchaseSubscription, which both stores' purchase paths call, with one map per store: Play's keyed by the token's hash, so made-up Play tokens cannot fill the App Store's. Distinct made-up tokens are still one call each.

Run: 005-subscriptions-load-timing, slice 6

* badge-service: read Play's paused subscription as waiting to renew

An unmapped SUBSCRIPTION_STATE_PAUSED was answered internal, so a paused subscriber's renewal failed for the whole pause. Play reports a pause with auto-renewal on and charges again when it ends, so it now maps as an active subscription does: renewing, or cancelled with auto-renewal off. Pause is being turned off on the base plan; this is defence in depth. A pause grants nothing: Play names no new order, and the last one is already granted.

Run: 005-subscriptions-load-timing, slice 9

* core: retire a subscription badge once no store could still charge it, whatever the service answers

A subscription badge retired only on an ended answer, so a service that never sent one (a final error such as unknown_purchase_key, a rollback, a service that never answers) left the badge shown, one request a day forever, and the profile unable to redeem, buy or have a held receipt credited. Now it also retires once paidThrough is older than the longest store recovery window: Apple retries a failed renewal for up to 60 days, the longest with Play's pause off, plus a week. paidThrough counts every month the ledger holds, so banked months are never retired by it.

Run: 005-subscriptions-load-timing, slice 8

* ios, android: no Restore purchases on Your badge, where a restored purchase could not be credited

Your badge shows only while a badge is shown, so a purchase Restore found there with no record on this device was held under a profile whose held receipts are not processed while it shows a badge: not credited, nothing said, and on Android acknowledged, ending Play's three-day refund. Restore stays on Check your order, the one screen where a restored purchase can be processed.

Run: 005-subscriptions-load-timing, slice 7

* review: round 4 fixes

R4-1: a subscription badge is asked about first and retired only after the service answers, judged on the ledger after that answer. Retiring before asking used the client's paidThrough, which moves only when the client asks, so a subscriber who had not opened the app for longer than the recovery window while the store kept charging lost the badge at the next launch and was never asked again. A request that is not answered retires nothing. Tests: testSubscriptionRetiredAfterRecoveryWindow changed, testIdleSubscriptionKeptPastWindow and testSubscriptionNotRetiredWithoutAnswer added.
R4-2: one snooze comment where two contradicted each other.
R4-3: the recovery window's comment names Play's clipped period start.
R4-4: the retirement tests use subscriptionRecoveryWindow.

* badge-service: name the lane each request waits on

The router answered "may this request ask a store" as a Bool; renewal checks are to get a lane of their own, which a Bool cannot say. requestLane now names the lane: main, purchase or renewal. Both store lanes still share one queue, so no request changes lane.

Run: 007-subscriptions-retirement-lane, slice 1

* badge-service: check renewals on a lane no purchase waits on

Renewal checks shared the store lane with every purchase, so made-up Play tokens, which anyone can send, and the weekly burst of genuine purchases delayed them. Renewal checks now wait on a lane of their own, which only a request signed by the purchase key it names, and due its hourly store check, can reach.

Run: 007-subscriptions-retirement-lane, slice 2

* badge-service: name the web limiter's fixed-window step

takeToken counted a request against a minute's limit inline. The step becomes takeFrom, a pure function, so the purchase path's Play read allowance can use the same rule. No behaviour change.

Run: 007-subscriptions-retirement-lane, slice 3a

* badge-service: read Play's subscriptions at most 300 times a minute for purchases

A Play subscription purchase is asked about without any offline check, so any token in the accepted grammar spends a read from the Subscriptions quota renewal checks share. The purchase path now takes one of a minute's 300 reads for each Play subscription it asks about, a tenth of Play's default quota; with none left, the claim is answered rate_limited with the seconds left in the minute and is not held, as Play was not asked. A claim already held takes no read, and App Store claims, verified offline first, take none.

Run: 007-subscriptions-retirement-lane, slice 3

* core: count the subscription retirement window from the first answer that renewed nothing

The window was counted from the client's paidThrough, which moves only when the client asks, so after months away the first answer that granted nothing for any reason, a transient error, Play's clipped last days or a billing retry, retired a subscriber whose store may still have been charging. The purchase now stores its unrenewed run: the first answered request after which the ledger covered nothing, and the latest one. The badge retires once the window has passed since the run started, still only after an answer; a renewal ends the run, and an answer that comes a window or more after the latest one starts a new run, since an absence or an outage that long may hide a renewal. The window stays 67 days, written as Apple's 60-day billing retry plus the week contacts give an expired credential.

Run: 007-subscriptions-retirement-lane, slice 4

* review: round 1 fixes

R1-A1: the docs and the PlayReads comment said the purchase path reads Play's subscriptions at most 300 times a minute; it asks about at most 300 subscriptions, and a credited one is read once more to acknowledge it, so made-up tokens can spend at most a tenth of the quota renewal checks share.
R1-A2: a comment on RequestLane repeated the one above the queues.
R1-B1: a test that a held pass, which sends no request, leaves the unrenewed run unchanged.
R1-B2: the outage test's last wait for the run is 30 s instead of 5 s.

Run: 007-subscriptions-retirement-lane, review round 1

* core: add the badge subscription columns in their own migration

M20261016_badge_store_receipts shipped to master in #7587, so it must
stay as released. Restore it in both dialects and move the
subscription_status, unrenewed_since and last_unrenewed_at columns,
with the down step that clears subscription_cancelled acknowledgements
master cannot read, into M20261016_badge_subscriptions.

Run: 008-subs-migration-split, slice 1

* tests: drop the FilePath import a merge duplicated

The branch and master each added import System.FilePath ((</>)) to
Direct.hs on 2026-10-14 at different positions, and an earlier merge
kept both, so GHC warned that one was redundant. Keep master's.

Run: 008-subs-migration-split, slice 2

* fixup! core: add the badge subscription columns in their own migration

* ios, android, desktop: choose a badge in the app

* ios, android, desktop: use the dev badge page for testing (don't merge)

* update postgres schema

* update ios lib

* ios, android: hide Restore purchases on the order screen

* docs: summarize the badge subscriptions PR for reviewers, with diagrams

A summary of PR #7716 as built: buying, renewing and retiring on each
store, the retirement window, lanes and store limits, RPC, API and
database changes, alerts, tests, open items, and a review map. Seven
SVG diagrams, an HTML page with them inline, and a link from the plan.

* ios, android, desktop: restore purchases on the Support SimpleX screen

* ios, android, desktop: show the badge privacy policy in the app

* docs: keep only the badge subscription diagrams, with a README

The reviewer summary leaves the repo; the folder keeps the seven
diagrams with a README that captions each, and the plan links the
README.

* ios, android, desktop: smaller Restore purchases label

* ios: no alert when a badge purchase is cancelled

* docs: replace the badge subscriptions diagrams README with a short summary

A plain-English summary for anyone on the team, with the seven
diagrams and a self-contained HTML page; the plan links it again.
Diagrams follow the branch: Restore purchases is on Support SimpleX,
"Renewing" shows past paidThrough, and only a present retryAfter is
raised to an hour.

* ui

* ui wip

* core: retire a subscription badge as soon as its store says it ended

A subscription badge retired only on an ended answer to a request that was due, and only once the ledger's paidThrough had passed, so an ended answer while the ledger still covered, or the iOS app's report of StoreKit's expired or revoked state, left the badge shown, Support SimpleX unreachable and every purchase refused until a later request. Now an answered ended state is stored even while coverage runs (the service writes it only from a store check, after which it grants nothing), and every pass retires a subscription whose state, from this pass's answer or else as stored, is ended, whatever the ledger covers. The paidThrough guard stays at the prepaid call site, its only remaining user.

Run: 010-subscription-lifecycle, slice S1

* docs: record that a badge subscription cannot be restored or bought again

Restoring a subscription on a new device or under another profile fails for two independent reasons, the client's restore reading only unfinished transactions on iOS and the service answering any key but the one it credited receipt_used, and a lapsed iOS subscriber cannot subscribe again on the same install either. Both gaps files record it in their own format, and both restorePurchases() say why reading the store's entitlements alone would not help.

Run: 010-subscription-lifecycle, slice S2c

* fixup! core: retire a subscription badge as soon as its store says it ended

* transfer plan

* shorter plan

* update plan

* badge-service: let a dev deployment ask its mock store while coverage runs

[dev] check_store_during_coverage = on makes every issueBadge for a
subscription ask the dev mock, during paid coverage too, and bank the
month of a charge it can grant, issued once coverage reaches it. So a
renewal can be watched by hand without waiting a month, and a mock that
forgot the plan after a restart answers ended at once.

Off by default, and refused unless accept_unverified_store_receipts is
on, which is itself refused beside a real store: a real store has not
started its next charge while coverage runs, and asking spends the
quota every subscriber shares.

Run: 011-subscription-tools, slice S3a

* core: send a badge request at once from a build for the dev badge service

/_badge request <userId> sends the profile's badge request now, while
its badge is shown, and runs the badge pass on the answer, so a renewal
or an ended subscription can be watched by hand against the dev
service.

It is allowed only when ChatConfig's badgeDevRequests is set, which no
shipped build does and nothing sets at runtime: the dev-service branch
sets it beside its own badge service overrides. A request off the shared
expiry would tell requests apart, so every other build refuses it, and
it is refused from a paired remote controller.

Run: 011-subscription-tools, slice S3b

* fixup! badge-service: let a dev deployment ask its mock store while coverage runs

* fixup! badge-service: let a dev deployment ask its mock store while coverage runs

* fixup! core: send a badge request at once from a build for the dev badge service

* core: split the badge request's lock from its body

The receipt worker's attempt already holds the owner's badge lock, which is not reentrant, so it cannot call requestBadgeIssue. The body becomes sendBadgeIssue, and both answer a service error as Left (code, retryAfter) instead of Nothing, so that caller can classify it as it does today. No behaviour change.

Run: 011-subscription-resume, slice S2-0

* core: retire on a device's ended report only once the paid period is over

StoreKit can report a subscription expired during Apple's billing retry, and Apple may then recover the charge, so retiring at once on that report left a paying user without the badge. The service's answered ended still retires at once, also while the ledger covers. A stored ended, which is how the device's report arrives, retires once paidThrough has passed.

Run: 011-subscription-resume, slice S1c

* core: resume a retired Apple subscription when its store charges it again

A same-install resubscription keeps Apple's original transaction id, so its hand-over found the old credited record and was answered from it with the old credential: the subscriber paid and got nothing. A credited record is now held again when a later charge of its subscription is handed over, the charge is not revoked, its paid period runs and it is dated less than a day ahead of this device's clock, and the record's purchase is its owner's newest, a subscription and not shown, with no other receipt held. The receipt worker then sends the purchase's issueBadge, and the service grants the charge as a renewal. A covered answer shows the purchase again with the newest credential and clears an ended state. A charge the service answers without crediting is deferred an hour and refused a day after its purchase date, so it holds the profile for about a day. A refused resume is remembered on the record (every credit, re-hold and resume clears credit_error), so the app's sweep does not hold the same charge again after its day.

Run: 011-subscription-resume, slice S2a

* fixup! core: resume a retired Apple subscription when its store charges it again

Review round 1 fixes:
- R1-1: a resumable charge waiting behind another held receipt of the owner is answered held and left unfinished, rather than answered from the record and finished uncredited.
- R1-3: a resume shows the purchase again only with a credential that has not expired.
- R1-2, R1-8: tests of the resumable-charge rule and the hold's edge, of a held charge whose paid period ends before it is sent, and that a resumed profile shows the newest credential. The resume test is split in two, one environment each.
- R2-V1: a test comment that the reordered steps made false.

Run: 011-subscription-resume, review rounds 1 and 2

* core, ios, android, desktop: let a lapsed iOS subscriber subscribe again to the badge the profile retired

The iOS app refused any subscription to an Apple ID that ever subscribed, so a lapsed subscriber could never subscribe again in the app. Core now tells the apps each subscription's store reference (BadgeSubscription.transactionRef, optional in JSON), and the iOS gate opens a subscription card only when every earlier subscription of the Apple ID is the active profile's newest purchase, retired, of the card's badge: the one charge core resumes. Kotlin gets the model field only, as Play gives a resubscription a new token and needs no gate.

Run: 011-subscription-resume, slice S2b

* docs: bound a badge move by the previous credential's expiry

* badge-service: credit test purchases and check the store during coverage in a dev deployment

* core: let a dev build ask the badge service on an interval

* badge-service: log each request's command and each store check's outcome

* fix compilation

* badge-service: name the purchase each request came from in the log

* badge-service: name the purchase each request came from in the log

* core, ios, android: explain a failed badge renewal and stop retrying a subscription the store forgot; tests (#7788)

* core, badge-service, ios, android: change badge tier in the store, and restore a subscription on another device (#7795)

* core: tell the apps when a badge request banks a month without issuing it

A pass announced the badge state only when the balance start moved, so a charge granted onto running coverage, or a debit the service wrote, changed months left and paid-through without an event, and the apps kept showing the old renewal date until the screen was reopened. The pass now announces whenever the ledger's last entry is not the one it started from, which a moved balance start implies.

Run: 015-badge-fixes, slice 1

* core: test that every Play charge of a month-end subscription funds its month

Play reports only a period's end, and the service dates the charge a plan length before it, so month-end clipping could leave a charge funding less than its month. Walk a monthly and an annual Play plan anchored on 31 January through their renewals at the device's request moments, through the deployed Play verifier: each charge funds exactly its months, paid-through stays at most the grace behind Play's period end (three days at most in this walk), and no charge is left without a grant.

Run: 015-badge-fixes, slice 2

* core: test that the newest issued badge credential is taken by its ledger row

After a tier change the newer issuance can end before the older one, so ordering issuances by period end would answer the superseded credential to the worker and to the resume and tier-change paths. Pin the row order: the test fails if the query goes back to period_end order.

Run: 015-badge-fixes, slice 3

* review: round 1 fixes

The App Store tier-change hold, reviewed for the first time:

- R1-B2: a tier change's credit now shows its purchase again (shown_badge_id) in the same transaction, as a resume's does, so a retire that ran while the change was with the service cannot leave a paid purchase unshown and never renewed.
- R1-B5: a tier change of a shown purchase is held whatever other receipt is held, as the worker serves it while the badge is shown; only a resume waits for other held receipts.
- R1-B3: test a tier change the store does not vouch for: refused in silence, the badge kept, and answered from the record after.
- R1-B8: the changeTier comment names when the record settles.

Run: 015-badge-fixes, review round 1

* review: round 2 fixes

- R2-1: a granted tier change clears an ended state the device reported before it. A renewing report never replaces ended, and a charge of the new tier granted proves the subscription has not ended, so without this the worker retired the new tier's badge at its paid-through while the subscription renewed.
- R1-B3: test a tier change whose credential does not verify: the failure stays on the receipt record, not the purchase, and Supporter stays shown.
- R2-3: the refused tier change's test buys Supporter through an invoice, so it shows the refusal announces nothing.
- R2-2: the changeTier comment says what follows each way the record settles.

Run: 015-badge-fixes, review round 2

* review: round 3 fixes

- R3-1: a tier change whose answer says the subscription ended is not credited, as a resume is not, so the credit's clearing of a stored ended never drops the one this answer carries.
- R3-2: the changeTier comment in two lines.

Run: 015-badge-fixes, review round 3

* core: apply an App Store tier change at once only when it raises the tier

Apple keeps charging the old plan to the end of the period, so applying a downgrade at once took away a badge the subscriber had already paid for. A charge of another tier is now held and sent at once only from supporter to legend; a lower tier, or one that is not ranked (investor, unknown), is answered from the record and the renewal check grants it, as badges-rpc.md says. A record already held for such a change settles silently.

Run: 016-tier-change-direction

* core: test that an App Store change to a lower tier waits for the renewal

A Supporter charge handed over while Legend is shown is answered from the record and sends nothing, and the renewal check grants Supporter, shown once the Legend credential expires; a record an older build held for a lower tier settles with no request. Both fail if every change of tier is sent at once again.

Run: 016-tier-change-direction

* badge-service: let a restore ask the store without waiting for the purchase's own check

* docs: describe badge tier changes and restore as the service handles them

The RPC document splits a store tier change by store and direction: the
app never starts one, an App Store raise seen within a day of its charge
is sent as upgradeBadgeSubscription, every other change waits for the
renewal check, and a Play plan change is a new token, a new purchase.
transferBadge and its limits are checked against the handlers, and the
upgrades that are not built are marked so.

The overview admits what Restore discloses: the service links the two
purchase keys, a store receipt moves its subscription to whoever presents
it within the move limit, and a new badge or tier is shown as soon as it
is issued. The Android gap names what remains of Restore.

* docs: tell the badge subscriptions story with tier changes and restore

The summary now covers the whole lifecycle: buying, renewing, changing
tier, moving to another device or profile, and ending. The badge retires
as soon as the store says the subscription ended, a lapsed iOS subscriber
can resume the retired badge, an App Store raise is granted at once and
a lower tier at the renewal, and a Play plan change is a new purchase.
The known limits and the before-sale list follow the code.

The seven diagrams are updated, with stale file and line references
dropped, and two are added: a tier change and a restore. The page now
points at the diagram files instead of carrying copies of them.

* fixup! docs: describe badge tier changes and restore as the service handles them

* fixup! docs: tell the badge subscriptions story with tier changes and restore

* fixup! docs: describe badge tier changes and restore as the service handles them

* fixup! docs: tell the badge subscriptions story with tier changes and restore

* core: make the badge subscription tests fail on the defects they name (#7798)

* fix compilation

* fix api

* ios, android, desktop: open the badge page on the site again

* ios: choose a badge on the site, not in the app

* tests: hold the move with no charge to grant, not with the purchase's store check

* unfocus test

* update postgres schema

* badge-service: break a tie between issuances by their period, so both databases agree

* badge subscription review fixes (#7799)

* update badge subscriptions docs (#7802)

* core: show a changed badge tier to contacts as soon as it is granted

* update plans

* core: show a raised badge tier to contacts at once, hold back a lower one

* update sqlite schema

* badge subscriptions review fixes (#7903)

* update ios lib

* update query plans

* ios: one property wrapper on the chat view's chat (#7910)

* android: a shorter note under Restore purchases

* Revert "ios: one property wrapper on the chat view's chat (#7910)"

This reverts commit d5a24bb342.

* ci: larger RAM disk so tests do not run out of space

* update sqlite schema

* ios: preview references for the badge screens

---------

Co-authored-by: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com>
2026-10-28 13:54:10 +00:00

3327 lines
145 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
import StoreKit
@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, log: Bool = true) async -> APIResult<R>? {
let r: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum, log: log)
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, log: log)
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)
)
case .errorAgent(.AGENT(.A_SERVICE(.timeout))): (
title: NSLocalizedString("Connection timeout", comment: "alert title"),
message: NSLocalizedString("The service did not respond. Please try again.", comment: "alert message")
)
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 apiSetUserAutoAcceptGroupInvitations(_ userId: Int64, enable: Bool) async throws {
try await sendCommandOkResp(.apiSetUserAutoAcceptGroupInvitations(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 -> (AChatItem, ChatItemInfo) {
let r: ChatResponse0 = try await chatSendCmd(.apiGetChatItemInfo(type: type, id: id, scope: scope, itemId: itemId))
if case let .chatItemInfo(_, aci, chatItemInfo) = r { return (aci, 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 apiShareMyAddress(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool) async throws -> MsgContent {
let r: ChatResponse1 = try await chatSendCmd(.apiShareMyAddress(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, sign: Bool = false, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, sign: sign, 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) {
await MainActor.run { 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)? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiAddContact: no current user")
return 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) }
if let r { await MainActor.run { showAlert(connectionErrorAlert(r)) } }
return nil
}
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, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, resolveMode: resolveMode, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, planSimplexName, otherSimplexName, connPlan)) = r {
return ConnectionPlanResult(connLink: connLink, planSimplexName: planSimplexName, otherSimplexName: otherSimplexName, connectionPlan: connPlan)
}
// a .never (typing) search that matches nothing locally is not an error to surface
if case .error(.error(.notResolvedLocally)) = r { return nil }
if let r { await apiConnectResponseAlert(r) }
return nil
}
func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqType, PendingContactConnection)? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnect: no current user")
return 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)
case let .result(.sentInvitation(_, connection)):
return (.contact, connection)
case let .result(.contactAlreadyExists(_, contact)):
if let c = m.getContactChat(contact.contactId) {
ItemsModel.shared.loadOpenChat(c.id)
}
await contactAlreadyExistsAlert(contact)
return nil
default: ()
}
if let r { await apiConnectResponseAlert(r) }
return nil
}
private func apiConnectResponseAlert<R>(_ r: APIResult<R>) async {
await MainActor.run {
switch r.unexpected {
case .error(.invalidConnReq):
showAlert(
NSLocalizedString("Invalid connection link", comment: ""),
message: NSLocalizedString("Please check that you used the correct link or ask your contact to send you another one.", comment: "")
)
case .error(.unsupportedConnReq):
showAlert(
NSLocalizedString("Unsupported connection link", comment: ""),
message: NSLocalizedString("This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.", comment: "")
)
case let .error(.simplexDomainNotReady(domain, err)):
switch err {
case .noValidLink:
showAlert(
NSLocalizedString("No valid link", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but it has no valid link.", comment: ""), domain.fullDomainName)
)
case .unknownDomain:
showAlert(
NSLocalizedString("Unconfirmed name", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner.", comment: ""), domain.fullDomainName)
)
}
case .errorAgent(.NO_NAME_SERVERS):
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: NSLocalizedString("None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link.", comment: "")
)
case .errorAgent(.SMP(_, .AUTH)):
showAlert(
NSLocalizedString("Connection link removed", comment: ""),
message: NSLocalizedString("Your contact removed this link, or it was a one-time link that was already used.\nTo connect, ask your contact to create a new link.", comment: "")
)
case let .errorAgent(.SMP(_, .BLOCKED(info))):
showAlert(
NSLocalizedString("Connection blocked", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Connection is blocked by server operator:\n%@", comment: ""), info.reason.text),
actions: {[
okAlertAction,
UIAlertAction(title: NSLocalizedString("How it works", comment: ""), style: .default) { _ in
DispatchQueue.main.async {
UIApplication.shared.open(contentModerationPostLink)
}
}
]}
)
case .errorAgent(.SMP(_, .QUOTA)):
showAlert(
NSLocalizedString("Undelivered messages", comment: ""),
message: NSLocalizedString("The connection reached the limit of undelivered messages, your contact may be offline.", comment: "")
)
case let .errorAgent(.INTERNAL(internalErr)):
if internalErr == "SEUniqueID" {
showAlert(
NSLocalizedString("Already connected?", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("It seems like you are already connected via this link. If it is not the case, there was an error (%@).", comment: ""), internalErr)
)
} else {
showAlert(connectionErrorAlert(r))
}
case let .errorAgent(.SMP(serverAddress, .NAME(nameErr))):
switch nameErr {
case .NOT_FOUND:
showAlert(
NSLocalizedString("Name not found", comment: ""),
message: NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")
)
case .NO_RESOLVER:
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Server %@ does not support name resolution. Configure servers, or use a connection link.", comment: ""), serverAddress)
)
case let .RESOLVER(resolverErr):
showAlert(
NSLocalizedString("SimpleX name error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Resolver error: %@", comment: ""), resolverErr)
)
}
default: showAlert(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 link removed", comment: "conn error description")
case let .errorAgent(.SMP(_, .BLOCKED(info))):
String.localizedStringWithFormat(NSLocalizedString("Connection blocked: %@", comment: "conn error description"), info.reason.text)
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 {
String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "conn error description"), responseError(e))
}
}
}
func contactAlreadyExistsAlert(_ contact: Contact) async {
await MainActor.run {
showAlert(
NSLocalizedString("Contact already exists", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("You are already connected to %@.", comment: ""), contact.displayName)
)
}
}
private func connectionErrorAlert<R>(_ r: APIResult<R>) -> (title: String, message: String?) {
networkErrorAlert(r) ?? (
title: NSLocalizedString("Connection error", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: ""), 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 { await 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 { await apiConnectResponseAlert(r) }
return nil
}
func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> Contact? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectContactViaAddress: no current user")
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectContactViaAddress(userId: userId, incognito: incognito, contactId: contactId))
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return contact }
if let r {
logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
await MainActor.run { showAlert(connectionErrorAlert(r)) }
}
return 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)), .error(.error(.userExists)): return nil;
default:
await MainActor.run {
showErrorAlert(r.unexpected, NSLocalizedString("Error saving profile", comment: "alert title"))
}
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
}
}
func showSetSimplexNameError<R>(_ r: APIResult<R>, isChannel: Bool) async {
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")
await MainActor.run {
showAlert(NSLocalizedString("Error saving name", comment: "alert title"), message: String.localizedStringWithFormat(format, domain.fullDomainName))
}
} else {
await 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:
await 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))
if case let .result(.acceptingContactRequest(_, contact)) = r { return contact }
if case .error(.errorAgent(.SMP(_, .AUTH))) = r {
await MainActor.run { showAlert(
NSLocalizedString("Connection link removed", comment: ""),
message: NSLocalizedString("The sender deleted the connection request.", comment: "")
) }
} else if let r {
if let networkErrorAlert = networkErrorAlert(r) {
await MainActor.run { showAlert(networkErrorAlert) }
} else {
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
await MainActor.run { showAlert(
NSLocalizedString("Error accepting contact request", comment: ""),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: ""), 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>) -> (title: String, message: String?)? {
if case let .error(e) = res {
getNetworkErrorAlert(e)
} else {
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, _ rtcSession: String?, _ rtcIceCandidates: String) async throws {
let extraInfo = WebRTCExtraInfo(rtcSession: rtcSession, 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 }
await 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 { await 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)
}
// log: false because the code is a bearer secret until it is redeemed - it is in the command.
// nil when the user cancels the retry alert.
func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: User, newBadge: Bool, badgeState: BadgeState?)? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiRedeemBadgeCode(userId: userId, code: code), log: false)
guard let r else { return nil }
// redeemedBadge is dropped: the user's profile carries what is shown
if case let .result(.badgeRedeemed(user, _, newBadge, badgeState)) = r { return (user, newBadge, badgeState) }
throw r.unexpected
}
// a refusal is thrown, as BREServiceError with the code
enum BadgePurchaseResult {
case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase])
case credited(user: User, badgeState: BadgeState?)
// listed, and no record here has it: another device's purchase on the same Apple ID
case notRecorded
}
// log: false because a store receipt is a bearer secret, like a badge code - it is in the command.
func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, origin: StoreReceiptOrigin, _ payment: ServicePayment) async throws -> BadgePurchaseResult {
let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, origin: origin, payment: payment), log: false)
switch r {
case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? [])
case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState)
case .storeReceiptNotRecorded: return .notRecorded
default: throw r.unexpected
}
}
// the device store's view of a subscription it charges, which changes what is shown and alerted, and retires its badge once ended
func apiSetBadgeSubscriptionStatus(_ status: BadgeSubscriptionState, _ payment: ServicePayment) async throws {
let r: ChatResponse2 = try await chatSendCmd(.apiSetBadgeSubscriptionStatus(status: status, payment: payment), log: false)
if case .cmdOk = r { return }
throw r.unexpected
}
func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String {
let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId))
if case let .badgeInvoice(_, invoiceId) = r { return invoiceId }
throw r.unexpected
}
// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened
func redeemErrorText(_ error: Error, purchase: Bool) -> String {
if case let .error(.badgeRedeemError(e)) = error as? ChatError {
switch e {
case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message")
case .invalidReceipt: break
case .serviceNotConfigured:
return purchase
? NSLocalizedString("This app version cannot buy badges.", comment: "alert message")
: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message")
case .badgeActive:
return purchase
? NSLocalizedString("This profile already has a badge.", comment: "alert message")
: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message")
case .storePurchaseOpen:
return purchase
? NSLocalizedString("A store purchase is being added to this profile.", comment: "alert message")
: NSLocalizedString("A store purchase is being added to this profile. Redeem the code once it is added, or on another profile.", comment: "alert message")
case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text }
case let .invalidResponse(message):
return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message)
case .unknownKeyIndex, .credentialNotVerified: return NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message")
}
}
if case .subscribedBefore? = error as? BadgeStoreError {
return NSLocalizedString("This Apple ID already has a badge subscription. Only the profile that bought it can continue it.", comment: "alert message")
}
if let e = error as? Product.PurchaseError {
switch e {
case .productUnavailable: return NSLocalizedString("This badge is not on sale.", comment: "alert message")
case .purchaseNotAllowed: return NSLocalizedString("In-app purchases are not allowed on this device.", comment: "alert message")
default: break
}
}
if let e = error as? StoreKitError {
switch e {
case .networkError: return NSLocalizedString("The store could not be reached.", comment: "alert message")
case .notAvailableInStorefront: return NSLocalizedString("This badge is not on sale.", comment: "alert message")
default: break
}
}
return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
}
func apiGetBadgeState(_ userId: Int64) async throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
func apiGetBadgeStateSync(_ userId: Int64) throws -> (badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) {
let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId))
if case let .badgeState(_, badgeState, storePurchases) = r { return (badgeState, storePurchases ?? []) }
throw r.unexpected
}
func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -> [StatementEntry] {
let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeLedger(userId: userId, badgePurchaseId: badgePurchaseId))
if case let .badgeLedger(_, badgeLedger) = r { return badgeLedger }
throw r.unexpected
}
func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? {
let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode))
if case let .badgeState(_, badgeState, _) = r { return badgeState }
throw r.unexpected
}
// An API call and not a stored flag: the ack is kept on the purchase in core, which then stops
// raising this occurrence on every pass and across restarts, or until a snooze lapses.
func ackBadgeAlert(snooze: Bool) async {
let badgeModel = BadgeModel.shared
guard let userId = badgeModel.userId, let purchaseId = badgeModel.badgeState?.badgePurchaseId, let alert = badgeModel.alert else { return }
do {
let badgeState = try await apiAckBadgeAlert(userId, purchaseId, alert.kind, snooze: snooze, episode: alert.episode)
await MainActor.run { badgeModel.set(userId: userId, badgeState: badgeState) }
} catch let error {
logger.error("ackBadgeAlert: \(responseError(error))")
}
}
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)
Task { await BadgeStore.shared.presentUnfinished() }
if shouldDeleteDatabaseBackupsDefault.get() {
deleteDatabaseBackups()
shouldDeleteDatabaseBackupsDefault.set(false)
}
}
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()
Task { await BadgeStore.shared.presentUnfinished() }
}
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)
Task { await BadgeStore.shared.presentUnfinished() }
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)
loadBadgeState()
}
// Not thrown: a failed badge read must not stop the app starting, and the model is left alone
// rather than set to nil, which would read as "no badge".
private func loadBadgeState() {
do {
let userId = try currentUserId("loadBadgeState")
let (badgeState, storePurchases) = try apiGetBadgeStateSync(userId)
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
func loadBadgeStateAsync(_ userId: Int64) async {
do {
let (badgeState, storePurchases) = try await apiGetBadgeState(userId)
await MainActor.run {
BadgeModel.shared.set(userId: userId, badgeState: badgeState)
BadgeStore.shared.setStorePurchases(userId, storePurchases)
}
} catch let error {
logger.error("loadBadgeState: \(responseError(error))")
}
}
func loadCurrentBadgeState() async {
if let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) {
await loadBadgeStateAsync(userId)
}
}
private func getUserChatDataAsync(keepingChatId: String?) async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if let userId = m.currentUser?.userId {
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)
}
await loadBadgeStateAsync(userId)
} 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.replaceConnReqView(conn.id, contact.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.replaceConnReqView(conn.id, contact.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.replaceConnReqView(conn.id, contact.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, _, _),
let .receivedGroupInvitationViaGroup(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.replaceConnReqView(conn.id, groupInfo.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.replaceConnReqView(hostConn.id, groupInfo.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)
if active(user), let file = aChatItem.chatItem.file, m.receivedFileToOpen(file) {
await MainActor.run { m.openReceivedFile(file) }
}
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, askConfirmation):
await withCall(contact) { call in
await MainActor.run {
call.callState = .offerReceived
call.sharedKey = sharedKey
}
if askConfirmation {
showUnencryptedCallAlert(call) {
Task { await processCallOffer(callType, offer, sharedKey) }
}
} else {
await processCallOffer(callType, offer, sharedKey)
}
}
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, session: extraInfo.rtcSession))
}
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)
}
}
case let .badgeChanged(user, badgeState):
await MainActor.run {
// read by core after retiring or presenting, so it carries the profile badge as changed
m.updateUser(user)
if active(user) {
BadgeModel.shared.set(userId: user.userId, badgeState: badgeState)
}
}
case let .badgeAlert(user, badgeAlert):
if active(user) {
await MainActor.run {
BadgeModel.shared.setAlert(userId: user.userId, alert: badgeAlert)
}
}
case let .storePurchaseCredited(user, invoiceId):
Task { await BadgeStore.shared.storePurchaseCredited(user, invoiceId) }
case let .storePurchaseRefused(user, invoiceId, refusal):
Task { await BadgeStore.shared.storePurchaseRefused(user, invoiceId, refusal) }
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 processCallOffer(_ callType: CallType, _ offer: WebRTCSession, _ sharedKey: String?) async {
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
))
}
func showUnencryptedCallAlert(_ call: Call, onContinue: @escaping () -> Void) {
DispatchQueue.main.async {
showAlert(
NSLocalizedString("Call is not encrypted", comment: "alert title"),
message: String.localizedStringWithFormat(NSLocalizedString("%@ accepted the call without end-to-end encryption.", comment: "alert message"), call.contact.displayName),
actions: {[
UIAlertAction(title: NSLocalizedString("End call", comment: "alert action"), style: .destructive) { _ in
// the call may have ended, or a new one started with the same contact, while the alert was shown
guard m.activeCall === call else { return }
if let uuid = call.callUUID {
CallController.shared.endCall(callUUID: uuid)
} else {
CallController.shared.endCall(call: call) {}
}
},
UIAlertAction(title: NSLocalizedString("Continue", comment: "alert action"), style: .default) { _ in
if m.activeCall === call { onContinue() }
}
]}
)
}
}
}
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]
}
}
}