From 9c7128d547091a9b6f1c356fd5bffd5964ed54a3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 6 Aug 2026 09:37:10 +0100 Subject: [PATCH 01/16] core: plan for supporter badges (#7325) * core: plan for supporter badges * ledger maths * update plan * language * lines * lists * redeem * badge rpc protocol and draft service schema/plan * update badge service protocol to support upgrades * badge purchase ledger types and schema * type, mvp plan --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- docs/protocol/badges-rpc.md | 64 +++ docs/protocol/badges-rpc.schema.json | 384 +++++++++++++ plans/2026-07-30-supporter-badges-v3-ux.md | 534 ++++++++++++++++++ .../2026-07-31-badges-core-implementation.md | 410 ++++++++++++++ plans/2026-07-31-badges-service-schema.sql | 146 +++++ plans/2026-08-04-badges-mvp-scope.md | 83 +++ simplex-chat.cabal | 2 + src/Simplex/Chat/Badges/Service.hs | 244 ++++++++ src/Simplex/Chat/Badges/Store.hs | 215 +++++++ .../Migrations/M20260731_user_badges.hs | 169 ++++++ 10 files changed, 2251 insertions(+) create mode 100644 docs/protocol/badges-rpc.md create mode 100644 docs/protocol/badges-rpc.schema.json create mode 100644 plans/2026-07-30-supporter-badges-v3-ux.md create mode 100644 plans/2026-07-31-badges-core-implementation.md create mode 100644 plans/2026-07-31-badges-service-schema.sql create mode 100644 plans/2026-08-04-badges-mvp-scope.md create mode 100644 src/Simplex/Chat/Badges/Service.hs create mode 100644 src/Simplex/Chat/Badges/Store.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md new file mode 100644 index 0000000000..635b2d8e7c --- /dev/null +++ b/docs/protocol/badges-rpc.md @@ -0,0 +1,64 @@ +# Badge service RPC protocol + +Schema: `badges-rpc.schema.json`, definitions `request` and `response`. Types: `Simplex.Chat.Badges.Service`. Model: `plans/2026-07-30-supporter-badges-v3-ux.md` §3 — cited below as "model". + +## Transport + +Service RPC (`plans/2026-07-22-service-rpc-chat.md`, branch `rpc`): the request travels in `APISendServiceRequest.request`, the response in `CRServiceResponse.responseData`; one response per request; per-call timeout. + +A request is an envelope: `version` — the client's protocol version; `purchaseKey`; `request` — the command, discriminated on `type`. Responses are discriminated on `type`. The service is deployed ahead of app releases, answers within the client's `version`, and rejects clients older than it supports with `unsupported_version`. + +## Identity + +Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`, and a key it holds no record of with `unknown_purchase_key`. + +A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `code`; `transferBadge` creates the receiving one (post-MVP). + +## Idempotency + +A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer. + +- `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. +- `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. +- `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. +- `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. +- `transferBadge` — a receipt used by the same key returns the same result; used by another key, `receipt_used`. + +## Commands + +`purchaseBadge`, `upgradeBadgeSubscription`, and `issueBadge` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3); its absence requests a lifetime credential; `badgeExtra` is reserved and must be empty. + +- `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. +- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr): `badgeType`, `months`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `expiresAt`, and `paymentTo` — `url` for card; `address` and `cryptoAmount` for crypto. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`), records the credit, and issues the first credential, in one round trip. `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. +- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). +- `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). +- `transferBadge` (post-MVP) → `badgeCredential` — the receipt moves the remaining balance and the provider binding to the signing key; the transferred period's re-issue debits a month, and the response holds a fresh receipt, retiring the presented one. Lifetime badges are not transferable by the command — support handles them. + +## Upgrades + +Always a new purchase under a new key, except store subscriptions, where the store owns the change. + +- Non-store: `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. +- Store one-time: an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. +- Store subscription, app-led: the native subscription-group flow (Apple — immediate, with the store's prorated refund; Google — per replacement mode), then `upgradeBadgeSubscription` with the new evidence. +- Store subscription, sheet-led, and every downgrade: the client sends nothing — the service discovers the change from provider state and notifications, and each renewal credits months of the charged badge type. + +## Catalog + +`prices` — `priceId`, `badgeType`, `monthPrice`, `currency`, `status`, `createdAt`; `offers` — `offerId`, `priceId`? (absent applies to any price), `months`, `discount`, `status`, `createdAt`. An offer states a discount, as free months or a percentage; a duration without one is priced at `months × monthPrice`. Repricing appends a price and deprecates the old, which is still accepted at invoice creation; deprecated prices and offers are sent so that a refresh cannot remove what the client pinned, and disabled ones are omitted. Rendering is app-driven — tiers and durations come from app resources, and one without a price is shown disabled. + +## Statement and balance + +The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. + +`statement` — `entries`, and `previousEntryId` when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states `entryId`, the signed `changeMonths`, the resulting `balanceMonths`, `balanceStartTs`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType` — `credit`: `payment {invoiceId?}`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. An unknown type is stored as received and decoded after an app upgrade. + +`balance` — `lastEntry`, the client's last entry, asserting the position and the months it believes it holds. + +An assertion that names an entry the service holds is a prefix: the service proceeds and returns what follows it. Otherwise the service heals its own ledger first — provider evidence for charges, time for lapses — proceeds, and returns either the complete history or one `opening` credit. An `opening` entry is an absolute restatement: the ledger is reset to the amount it states, without relation to the preceding entry, which also serves a new device and, later, the discarding of old history into a brought-forward balance. + +## Errors + +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown and revoked codes; `code_used` — redeemed under another key. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json new file mode 100644 index 0000000000..b1c3cf4a66 --- /dev/null +++ b/docs/protocol/badges-rpc.schema.json @@ -0,0 +1,384 @@ +{ + "metadata": { + "description": "JTD schema for badge service RPC requests and responses, see badges-rpc.md." + }, + "definitions": { + "base64url": { + "type": "string", + "metadata": {"format": "base64url encoded string"} + }, + "purchaseKey": { + "ref": "base64url", + "metadata": {"comment": "must match the RPC transport key"} + }, + "currencyAmount": { + "type": "uint32", + "metadata": {"comment": "USD etc. are in minor units, following Stripe etc. convention"} + }, + "badgeInfo": { + "properties": { + "badgeType": {"type": "string"}, + "badgeExtra": {"type": "string"} + }, + "optionalProperties": { + "badgeExpiry": {"type": "timestamp"} + } + }, + "badgeRequest": { + "properties": { + "masterKey": {"ref": "base64url"}, + "badgeInfo": {"ref": "badgeInfo"} + } + }, + "credential": { + "properties": { + "badgeKeyIdx": {"type": "uint16"}, + "masterKey": {"ref": "base64url"}, + "signature": {"ref": "base64url"}, + "badgeInfo": {"ref": "badgeInfo"} + } + }, + "paymentVia": { + "discriminator": "type", + "mapping": { + "card": { + "properties": {"provider": {"enum": ["stripe"]}} + }, + "crypto": { + "properties": {"currency": {"enum": ["btc", "xmr"]}} + } + } + }, + "paymentTo": { + "discriminator": "type", + "mapping": { + "card": { + "properties": { + "provider": {"enum": ["stripe"]}, + "url": {"type": "string"} + } + }, + "crypto": { + "properties": { + "currency": {"enum": ["btc", "xmr"]}, + "address": {"type": "string"}, + "cryptoAmount": {"type": "string"} + } + } + } + }, + "payment": { + "discriminator": "type", + "mapping": { + "apple": { + "properties": {"jws": {"type": "string"}} + }, + "google": { + "properties": {"token": {"type": "string"}} + }, + "invoice": { + "properties": {"invoiceId": {"type": "string"}} + }, + "code": { + "properties": {"code": {"type": "string"}} + } + } + }, + "upgrade": { + "properties": { + "fromPurchaseKey": {"ref": "base64url"}, + "receipt": {"type": "string"}, + "receiptSignature": {"ref": "base64url"}, + "balance": {"ref": "balance"} + } + }, + "catalog": { + "properties": { + "prices": {"elements": {"ref": "badgePrice"}}, + "offers": {"elements": {"ref": "badgeOffer"}} + } + }, + "badgePrice": { + "properties": { + "priceId": {"type": "string"}, + "badgeType": {"type": "string"}, + "monthPrice": {"ref": "currencyAmount"}, + "currency": {"type": "string"}, + "status": {"ref": "badgeItemStatus"}, + "createdAt": {"type": "timestamp"} + } + }, + "badgeOffer": { + "properties": { + "offerId": {"type": "string"}, + "months": {"type": "uint8"}, + "discount": {"ref": "offerDiscount"}, + "status": {"ref": "badgeItemStatus"}, + "createdAt": {"type": "timestamp"} + }, + "optionalProperties": { + "priceId": { + "type": "string", + "metadata": {"comment": "absent applies to any price"} + } + } + }, + "offerDiscount": { + "discriminator": "type", + "mapping": { + "freeMonths": { + "properties": {"freeMonths": {"type": "uint8"}} + }, + "discount": { + "properties": { + "discount": { + "type": "uint8", + "metadata": {"comment": "percent"} + } + } + } + } + }, + "badgeItemStatus": { + "enum": ["active", "deprecated", "disabled"], + "metadata": {"comment": "disabled is not sent"} + }, + "statement": { + "properties": { + "entries": {"elements": {"ref": "ledgerEntry"}} + }, + "optionalProperties": { + "previousEntryId": { + "type": "string", + "metadata": {"comment": "matches the client's asserted entryId, absent for the full ledger"} + } + } + }, + "balance": { + "properties": { + "lastEntry": {"ref": "ledgerEntry"} + } + }, + "ledgerEntry": { + "properties": { + "entryId": {"type": "string"}, + "changeMonths": {"type": "int16"}, + "balanceMonths": {"type": "uint16"}, + "balanceStartTs": {"type": "timestamp"}, + "balanceBadgeType": {"type": "string"}, + "createdAt": {"type": "timestamp"}, + "entryType": {"ref": "ledgerEntryType"} + }, + "optionalProperties": { + "wasPausedSince": {"type": "timestamp"} + } + }, + "ledgerEntryType": { + "discriminator": "type", + "mapping": { + "credit": { + "properties": {"credit": {"ref": "ledgerCredit"}} + }, + "debit": { + "properties": {"debit": {"ref": "ledgerDebit"}} + } + } + }, + "ledgerCredit": { + "discriminator": "type", + "mapping": { + "payment": { + "optionalProperties": { + "invoiceId": { + "type": "string", + "metadata": {"comment": "absent for store and code payments"} + } + } + }, + "charge": { + "properties": {"chargeId": {"type": "string"}} + }, + "support": { + "optionalProperties": {} + }, + "transferIn": { + "properties": {"fromPurchaseKey": {"ref": "base64url"}} + }, + "opening": { + "optionalProperties": {} + } + } + }, + "ledgerDebit": { + "discriminator": "type", + "mapping": { + "refund": { + "optionalProperties": {} + }, + "upgrade": { + "properties": {"toPurchaseKey": {"ref": "base64url"}} + }, + "transferOut": { + "properties": {"toPurchaseKey": {"ref": "base64url"}} + }, + "support": { + "optionalProperties": {} + }, + "badge": { + "optionalProperties": {} + }, + "lapse": { + "optionalProperties": {} + } + } + }, + "request": { + "properties": { + "version": {"type": "uint16"}, + "request": {"ref": "command"} + }, + "optionalProperties": { + "purchaseKey": { + "ref": "purchaseKey", + "metadata": {"comment": "optional for getBadgeCatalog, required for other commands"} + } + } + }, + "command": { + "discriminator": "type", + "mapping": { + "getBadgeCatalog": { + "optionalProperties": {} + }, + "getBadgeInvoice": { + "properties": { + "priceId": {"type": "string"}, + "badgeInfo": {"ref": "badgeInfo"}, + "paymentVia": {"ref": "paymentVia"} + }, + "optionalProperties": { + "offerId": { + "type": "string", + "metadata": {"comment": "absent for 1 month at the badge price"} + }, + "upgrade": { + "ref": "upgrade", + "metadata": {"comment": "upgrade non-store badge"} + } + } + }, + "purchaseBadge": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "payment": {"ref": "payment"} + }, + "optionalProperties": { + "upgrade": { + "ref": "upgrade", + "metadata": {"comment": "upgrade store 1-time badge"} + } + } + }, + "upgradeBadgeSubscription": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "payment": { + "ref": "payment", + "metadata": {"comment": "store payments only"} + }, + "balance": {"ref": "balance"} + } + }, + "issueBadge": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "balance": {"ref": "balance"} + } + }, + "pauseBadge": { + "optionalProperties": {} + }, + "transferBadge": { + "properties": { + "badgeRequest": {"ref": "badgeRequest"}, + "receipt": {"type": "string"} + } + } + } + }, + "response": { + "discriminator": "type", + "mapping": { + "badgeCatalog": { + "properties": { + "catalog": {"ref": "catalog"} + }, + "optionalProperties": { + "badgeStatement": { + "ref": "statement", + "metadata": {"comment": "for signed getBadgeCatalog"} + } + } + }, + "badgeInvoice": { + "properties": { + "invoiceId": {"type": "string"}, + "badgeType": {"type": "string"}, + "months": {"type": "uint8"}, + "price": {"ref": "currencyAmount"}, + "amount": { + "ref": "currencyAmount", + "metadata": {"comment": "price - discount - credit"} + }, + "currency": {"type": "string"}, + "expiresAt": {"type": "timestamp"}, + "paymentTo": {"ref": "paymentTo"} + }, + "optionalProperties": { + "discount": { + "ref": "currencyAmount", + "metadata": {"comment": "discount amount from monthly price"} + }, + "credit": { + "ref": "currencyAmount", + "metadata": {"comment": "credit for upgrade"} + } + } + }, + "badgeCredential": { + "properties": { + "statement": {"ref": "statement"} + }, + "optionalProperties": { + "credential": { + "ref": "credential", + "metadata": {"comment": "absent when no balance to issueBadge or no current credential for pause"} + }, + "receipt": { + "type": "string", + "metadata": {"comment": "not provided for lifetime badges"} + } + } + }, + "error": { + "properties": { + "code": { + "enum": [ + "bad_request", "unsupported_version", "unknown_purchase_key", + "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", + "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", + "code_invalid", "code_used", "code_expired", + "receipt_invalid", "receipt_used", + "internal" + ] + } + }, + "optionalProperties": { + "message": {"type": "string"}, + "retryAfter": {"type": "uint32"} + } + } + } + } + } +} diff --git a/plans/2026-07-30-supporter-badges-v3-ux.md b/plans/2026-07-30-supporter-badges-v3-ux.md new file mode 100644 index 0000000000..12b75acf0a --- /dev/null +++ b/plans/2026-07-30-supporter-badges-v3-ux.md @@ -0,0 +1,534 @@ +# Supporter Badges v3 + +**Date:** 2026-07-30 +**Status:** draft +**Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`): signed requests (`APISendServiceRequest.signKey`), verified signer key delivered to the service (`CEvtServiceRequest.signerKey`), one response per request, per-call timeout, no transport persistence. The client repeats the same signed request after a timeout; every bot operation is idempotent. +**Designs:** `/code/simplex-chat-art/screens/badges-mobile_15-*.jpg`, `_16-*-dark.jpg` + +## Principles + +- At most one badge is shown per profile. A user holds at most two badges per profile: paid (supporter/legend) and investor (2.7). +- The core engine performs all badge automation and emits events; apps render and call APIs only for user actions (2.9). +- No bot push; the client reads state via its own requests. Bot-initiated messaging, if ever added, is an opt-in SimpleX contact. +- Paid time is a months balance on a single-entry ledger; unconsumed months lapse; credentials are issued monthly for all plans; prepaid time is pausable post-MVP (§3, 2.13). +- One current alert per profile, derived from state; a later state supersedes an unacknowledged earlier alert (2.4). +- Renewal reminders on by default with opt-out; no success screens. +- Credentials expire on Sundays (UTC); profile updates are sent on Mondays, including removal updates for expired badges; the user is shown one date — the payment date (2.11). + +## 1. Existing designs + +| Screen | Covers | +|---|---| +| `banner` | chat-list banner: "Support SimpleX — Get badge + files up to 5GB", dismissable | +| `support-simplex` | intro screen → Choose your level | +| `your-level` | tier choice: Supporter $7/month (2GB) vs Legend $70/month (5GB), profile preview with badge | +| `pay` | plan choice: "1 month" vs "Subscribe", Pay button, renewal date | +| `settings` | "Supporter perks" row in Your settings | +| `whats-new` | v7.0 announcement entry | + +These designs cover the acquisition path for iOS/Play builds only. Missing designs: + +- payment method + duration selector (2.1) +- crypto invoice screen (2.1) +- user-picker badge presence (2.3) +- management screen (2.6) +- start-sharing question (2.5) + +Store builds display store-fetched localized prices; catalog prices apply to Stripe/BTCPay only. + +## 2. UX + +### 2.1 Payment method and duration (non-store builds) + +- Store builds: no method UI. One-time 1 month; monthly/annual subscriptions. +- Non-store builds: method selector (Card / Bitcoin / Monero), all multi-month prepaid; no card subscriptions (§6.11). Plan screen copy: "payments don't renew — you choose how many months to pay for". +- Durations 1 / 3 / 12 months; prices 1× / 2× / 6× monthly (§6.12). Duration selector — new design. +- Unavailable options: disabled offers and inactive products are shown as disabled; when no option is available, the purchase screen shows an unavailable notice; the `product_unavailable` error is displayed the same way. +- Crypto payment screen (new design): + - amount, one-off address, QR, copy, open-in-wallet, countdown (BTCPay fixes the fiat rate at invoice creation; the invoice expires in ~15–60 min); + - states: waiting → processing (transaction seen, unconfirmed) → settled; expired → "Invoice expired" + new invoice (new payment row); + - fiat-first display; + - partial payment: the BTCPay marked state is shown; the user is directed to support. +- After settlement the issue response contains a receipt code (§3 recovery); one-time save prompt at checkout ("keep your receipt — it recovers unused months if you lose this device"); receipts are shown in payment history (2.6). +- Stripe: payment link opened in the system browser; "Waiting for payment…" + open-link-again; the link is stored in the payment row. + +### 2.2 Chat-list banner + +Shown after ≥ 3 conversations and not in the first days after install; hidden for ~6 months after dismissal; not shown while a badge is active or a payment is pending; not re-shown on expiry. Exact thresholds set at implementation. + +### 2.3 User picker (design needed) + +Menu opened from the avatar in the chat-list toolbar (`apps/multiplatform/.../chatlist/UserPicker.kt`, `apps/ios/Shared/Views/ChatList/UserPicker.swift`; the profile bar renders `localBadge` — UserPicker.kt:213). + +- Option A (preferred): badge icon on the right of the profile bar, opens the management screen; indicator dot for the current alert. Icon shown while a badge is held or a payment is pending; acquisition entry remains the banner and the Settings row. +- Option B: full option row (state text: "Supporter · renews Aug 21", "ends Oct 22", "⚠ payment problem"), replacing the Settings row; "Your chat profiles" is moved to Settings. + +### 2.4 Alerts + +| Alert | When | Content / actions | +|---|---|---| +| subscription renewal approaching | 3 days before `renews_at`, once per cycle | price + date; OK · Remind me again (1 day before) · Cancel path | +| payment failed / grace | on provider grace/hold state, once per episode | "Payment problem — perks until {date}. Fix payment." → store fix flow | +| subscription ended | at paid-through, once | "Subscription ended {date}." + Resubscribe | +| prepaid ending | 3 days before paid-through, balance 0, not paused, once | "Your support ends {date}. Renew for {price}?" OK · Remind me again (1 day before) | +| support ended | at paid-through, balance 0, no active subscription, not paused, once | "Your support ended {date}." + Renew / Choose level | +| renewal charged | never — visible in charge history and picker badge state | — | +| credential issued | never | — | + +No alerts for paused badges (2.13). An alert is shown in its profile's context (indicator on the profile row in the picker). + +Persistence: no alerts table. On the badge row: `alert_acked_kind` + `alert_acked_episode` (the episode value, e.g. this cycle's `renews_at`) and `alert_snooze_until`. Core emits `CEvtBadgeAlert` when the derived alert differs from the acknowledged one; on OK the client stores the kind + episode; on snooze it sets `alert_snooze_until`, after which the alert is emitted once more. + +Opt-out toggle "Remind me about renewals and expiry" disables renewal-approaching and prepaid-ending; the other alerts cannot be disabled. + +### 2.5 Start-sharing + +- Default: a newly acquired badge is presented immediately; switching the shown badge takes effect immediately. +- Perks require presentation: the recipient verifies file size against the presented proof (`maxXFTPFileSize` on `PeerBadge`); the sender's client disables the perk at paid-through (2.11). This is stated in the "Now / From Monday" question. +- Management-screen toggle: "Show new badges from Monday" — off by default. +- Global setting "Show advanced privacy questions" (Privacy & security): when on, acquisitions that change the presented badge ask "Start showing: Now / From Monday". Later candidates for the same setting: deletion notifications, rejected contact requests, call questions. +- While presentation is delayed: the previous badge remains presented; the management screen shows "starts Monday {date}"; field `use_from`. + +### 2.6 Management screen (design needed) + +All dates shown are paid-through (2.11): + +1. **Acquiring / payment pending** — method-specific pending UI (2.1); old badge unchanged. +2. **Active prepaid** — "Ends {date}" ({N} months left); payment history (last 12; Stripe/crypto payments with receipts, §3); Pause (2.13); Subscribe; Upgrade. +3. **Active subscription** — "Renews {date} · {price}"; Cancel; Upgrade; charge history (last 12: date, amount, method, in the charged currency); reminders toggle. +4. **Cancelled, still active** — "Ends {date}"; Resubscribe. +5. **Payment issue** — banner over state 3; perks remain enabled during the provider grace period. +6. **Paused** — "Paused · {N} months left"; Resume. +7. **Expired** — "Support ended {date}"; Renew / Choose level. +8. **Held badges** — list of held badges (validity, source); radio for the shown one; pending presentation ("starts Monday {date}"). On a switch the client sends a profile update. +9. **Redeem code** entry (2.8). +10. Settings: reminders toggle (2.4); "Show new badges from Monday" (2.5). + +### 2.7 Held badges and switching + +- Perks apply only from the shown badge, verified against the presented proof. Holding legend while showing investor yields 2 GB (`BTLegend` alone yields 5 GB). Switch UI copy: "Sending 5 GB files requires showing the Legend badge". +- Exactly one shown; on a switch the client sends a profile update with a fresh proof (`setUserBadge` + broadcast; the presentation copy is stored in `contact_profiles`). Timing per 2.5. +- When the shown paid badge expires and an investor badge is held: the client switches the shown badge to investor with the Monday update. + +### 2.8 Redeem codes + +The service grants any badge type for 1/3/12 months per code (investor: lifetime); the response contains badge type + granted months. Granted months are recorded in the ledger; credentials are re-issued monthly. UX: "Have a code?" → paste → signed `redeem` (fresh order) → the badge is shown (2.5). Errors: invalid / used / expired, inline. + +### 2.9 Core engine + +Triggers: + +- chat start +- foreground +- network restore +- profile switch +- timers: renewal, expiry, Monday boundaries + +1. resume: settled payments without issuance; subscription renewals after `renews_at`; monthly re-issue while balance > 0 and not paused; invoiced/pending crypto payments; +2. `advance(now)` + `issue(now)` at each `issue`/`status` request (§3); +3. presentation on/after Monday (UTC): fresh proofs for renewed badges and for badges whose `use_from` was reached; removal updates for badges expired unrenewed; +4. alert derivation (2.4), `CEvtBadgeAlert`; +5. sync: store charges and ledger rows received after the `since` cursor (§3). + +Events to the app: `CEvtBadgeChanged`, `CEvtBadgeAlert`. + +API calls (user actions): + +- get status +- get catalog (purchase-screen open, §3 prices) +- start purchase / get invoice +- issue after store payment (the app passes store evidence to core) +- cancel — opens the store sheet; no bot op +- pause / resume +- switch shown badge / set start date +- acknowledge / snooze alert +- reminder and presentation toggles +- redeem code + +On `ASETimeout` core retries the identical signed envelope on the next trigger; never on a poll timer. + +### 2.10 Upgrades + +- supporter → legend (store subscription): native upgrade flow, same subscription group. New badge row (new keys); old row superseded. +- prepaid upgrades: legend invoice = legend price − full supporter price while the supporter badge is unexpired; remaining supporter months are removed by `debit(conversion)` (§3). Both amounts are displayed. +- one-time → subscription (same tier): same badge row, new payment row; `payment_id` is updated to the new payment. +- Presentation per 2.5, including investor → legend. + +### 2.11 Dates + +- Credential expiry = paid-through of the issued month rounded up to Sunday (end of Sunday, UTC) — `sundayAfter` (§3). +- The user is shown one date — paid-through: "Renews {date}" / "Ends {date}" / "Support ended {date}". "Badge valid until" does not appear anywhere. The sender's client disables perks at paid-through; the recipient's client enforces credential expiry + grace. Small print: "your profile badge may remain visible to contacts for a few days after your support ends." +- Monday send-outs: fresh proofs for renewed badges; removal updates (`setUserBadge Nothing`) for badges expired unrenewed. +- Recipient display: a peer badge with expiry E is displayed as active until E + 7 days; the `BSExpiredOld` boundary (31 days) is shifted by the same 7 days. Both in the recipient's `mkBadgeStatus` derivation. + +### 2.12 Multi-profile + +Badges, payments, charges, ledgers are per `user_id`. No badge is presented for incognito profiles. + +### 2.13 Pause (prepaid only, post-MVP) + +Issued credentials are not pausable (expiry is signed); re-issuance is. Pause suspends issuance and lapse, with no duration limit. + +- Prepaid time only (crypto, Stripe one-time, codes). +- Signed RPC op, recorded bot-side and mirrored (`resume` transition, §3). +- While paused: the current credential remains valid to its expiry; no alerts; management screen state 6. +- On resume: issuance is resumed at the next `issue` request. + +## 3. Model + +### Ownership — two databases + +Bot tables (no private keys, no master keys, no UI state): + +- `orders` — `order_key` PK; pinned `product_id`; timestamps. Order state is derived from the ledger + provider. +- `payments` — user-initiated payments, as opposed to charges: bot-assigned `payment_ref`; per-payment `offer_id`, `provider`, `provider_ref`, receipt hash, status +- `charges` +- `badge_ledger` — authoritative +- `issuances` + cached credentials +- `codes` — code hashes, granted type/months, redemption, batch revocation +- webhook dedup + +Client tables: + +- `products`, `offers` — catalog cache +- `payments` +- `charges` — replica +- `badge_ledger` — replica +- `issuances` — replica +- `badges` — keys, credential, UI state + +Rules: + +- The replicated tables (`badge_ledger`, `issuances`, `charges`) have identical row content on both sides: bot-assigned row ids and refs. Ledger and issuance account column: `order_key` on the bot, `badge_id` on the client — 1:1, one key per badge. Charges reference payments on both sides: `payment_ref` on the bot, `bot_payment_ref` on the client. +- Synced via the `since` cursor; all ledger transitions (§ ledger) are executed by the bot only; the client stores received rows unchanged. +- Not replicated: the client's `payments` row records the local act (evidence, invoice URL, status); the bot's `payments` row records the binding (offer, `provider_ref`, receipt hash); `orders` records the pinned product; `badges` has no bot counterpart. +- Payments are product-agnostic on both sides: keyed by `order_key`, without references to product tables. The product row references its current payment; payment history is the join on `order_key`. + +### `products` + `offers` — two-tier catalog + +Tier 1 — `products`. All builds use it for product structure; display names and localization are app resources. + +- `product_id` PK +- `product_type` — `badge` +- `badge_type` — `supporter|legend|investor` +- `active` + +Tier 2 — `offers`: + +- `offer_id` PK +- `product_id` → products +- `plan` — `one_time|monthly|annual` +- `months` — 1/3/12 for one-time; NULL for subscriptions +- `apple_product_id`, `google_product_id` — store offers +- `price`, `currency` — non-store offers +- `state` — `active|deprecated|disabled` + +Rules: + +- Offers are append-only: repricing = new offer row + deprecation of the old. +- Lifecycle: `active` (rendered + accepted) → `deprecated` (hidden; accepted by `order`; window length is operator policy) → `disabled` (`order` rejects with a refresh-catalog error). +- State is checked at invoice creation only; the amount of an existing invoice does not change; `issue` on a settled payment is always honored. +- For store offers the state controls display only; their sale lifecycle is managed in the store consoles. +- `catalog` response: `active` + `deprecated` offers with state; `disabled` omitted. The client renders `active`, retains `deprecated`, may delete offers absent from the response unless referenced by a payment row. +- The duration selector (2.1) is built from active one-time offers; discounts are per-duration prices. +- Store one-time is 1 month only (§6.12). Seeded from app config; server-authoritative on the bot. +- Forward compatibility: entries with unknown `product_type`/`badge_type` are stored, not rendered, no error. New product types require an app release; new offers of known products do not. +- Products with `active = false` are not offered; an in-flight request on one → `product_unavailable`. + +Prices: + +- The amount at invoice creation is authoritative: BTCPay fixes the fiat amount, the Stripe page shows the charge, the final amount is confirmed at checkout. +- Display prices: the unsigned `catalog` op on purchase-screen open (non-store builds; non-blocking — the screen is rendered from the stored catalog and updated from the response), and the full catalog included in every `issue`/`status` response; the client reconciles received rows against stored rows. +- The catalog shipped with the app is the offline fallback; store builds read prices from StoreKit/Play Billing. +- Repricing applies to future purchases only. + +### `payments` — user-initiated acts + +- `payment_id` PK +- `user_id` +- `order_key` — the order paid for; badge orders are rows in `badges` +- `offer_id` → offers +- `bot_payment_ref` — bot-assigned; recorded from the invoice response or sync rows +- `months`, `amount`, `currency` — copied from the offer at purchase +- `provider` — `apple|google|stripe|btc|xmr|code` +- `provider_ref` — Apple original transaction id / Google purchase token / Stripe intent ref / BTCPay invoice id / code hash +- `invoice_url` +- `evidence` — Apple JWS / Google token, stored for repeat `issue` requests +- `receipt_code` — client-side (§ recovery); the bot stores `receipt_hash` +- `status` — `new|invoiced|pending|settled|failed|expired` +- `renews_at` — subscriptions +- `cancelled` — bot-confirmed renewal-off +- `created_at`, `updated_at` + +One row per act: purchase, subscribe, upgrade, resubscribe, each crypto invoice, code redemption. Abandoned attempts remain as history. On settlement the bot runs `advance` then `grant` (§ ledger). `paidThrough` = last ledger row; `badge_expiry` = last issuance. + +### `charges` — provider-initiated billing events + +- `charge_id` PK +- `payment_id` → payments +- `bot_payment_ref` — the synced ref; the client resolves `payment_id` from it +- `provider_charge_ref` — Stripe invoice id / Apple transaction id / Google order id +- `period_start`, `period_end` +- `amount`, `currency` — as reported by the provider +- `charged_at` +- unique (`payment_id`, `provider_charge_ref`) + +Rules: + +- Sources — the first charge of a subscription is also a row: + - Stripe: invoices by subscription + - Apple: Get Transaction History by original transaction id + - Google: per-renewal order ids +- Synced to the client via the `since` cursor. +- For each settled charge the bot records a `grant` of the charge's period length: +1 monthly, +12 annual. +- Charges are money facts referenced by grants, not ledger rows: a grant is not recorded for every charge (webhook replays are rejected by the unique key before any grant; charges from provider history for consumed, refunded, or re-bound periods are recorded without grants), and `charged_at` differs from the accounting time. + +### `badge_ledger` + `issuances` + +**Operations to support:** + +| # | operation | months | credential | +|---|---|---|---| +| O1 | prepaid settlement (Stripe/BTCPay) | +N | — | +| O2 | subscription charge settled | +1 monthly / +12 annual | — | +| O3 | code redemption | +N | — | +| O4 | goodwill grant | +N | — | +| O5 | transfer in | +M | — | +| O6 | refund / chargeback | −balance | issued credential remains valid | +| O7 | upgrade conversion | −balance | — | +| O8 | transfer out | −balance | — | +| O9 | correction (code abuse) | −balance | — | +| O10 | issue a month | −1 | new credential | +| O11 | lapse elapsed months | −k | — | +| O12 | repeat issue, same month | none | cached credential, no rows | +| O13 | lifetime issuance (investor) | none — no ledger | new credential | +| O14 | pause / resume (post-MVP) | 0 | — | + +**Ledger state** — two values; every row contains the state after it; **the last row is the state**: + +- `months` — unused months. +- `start` — the date the unused balance starts. Not changed by grants (while `months > 0`); advanced by one month per `consume` and by the lapsed count per `lapse`. + +Coverage = `[start, addMonths months start)`. `paidThrough = addMonths months start` — read from the last row alone; not a `badges` column. + +**Row:** + +- `entry_id` PK +- `badge_id` +- `op` — `grant(source) | debit(reason) | consume | lapse | resume` +- `delta` — signed months change +- `months` — state: unused months after this row +- `start` — state: balance start after this row +- ref — bot-assigned payment ref / `charge_id` (grants) +- `created_at` + +Append protocol: lock the badge's ledger → read the last row → compute the next → insert. The client stores rows verbatim from responses (`since` cursor = last `entry_id` held) and can re-verify each row from its predecessor. + +**Transitions** — from the last row `(start, months)`; `advance t` is run before every issue, grant, and debit: + +``` +advance t: -- time bookkeeping only: one lapse row for the fully elapsed months + k = min months (fullMonthsBetween start t) + -- fullMonthsBetween start t: the largest m >= 0 with addMonths m start <= t + if k > 0: append (lapse, −k, months − k, addMonths k start) -- O11, one row + +issue t: -- run after advance t + requires months > 0 && start <= t && no issuance for [start, addMonths 1 start) + sign the credential, expiry sundayAfter (addMonths 1 start) + in one transaction: append (consume, −1, months − 1, addMonths 1 start) -- O10 + + issuance row for [start, addMonths 1 start) + on signing failure: no rows; retried at the next `issue` + +grant t n src: -- O1–O5; t = settlement time, + months == 0 → append (grant src, +n, n, max start t) -- provider period start for O2 + months > 0 → append (grant src, +n, months + n, start) + +debit reason: append (debit reason, −months, 0, start) -- O6–O9 + +resume t: append (resume, 0, months, max start t) -- O14; no row is written on pause +``` + +**Issuances** — separate table; no credential is issued for `grant`/`debit`/`lapse` rows, and no ledger rows are written for lifetime (O13) and cached (O12) issuance events: + +- `issuance_id` PK +- `badge_id` +- `period_start`, `period_end` — NULL for lifetime +- `expiry` — `sundayAfter period_end`; NULL for lifetime +- `entry_id` → the `consume` row; NULL for lifetime +- `created_at` + +`badgeExpiry` = `expiry` of the last issuance — the credential's disclosed field. The current credential is stored in the badge row's credential columns; the issuance rows are the history; the O12 check reads the last issuance's period. + +**Properties:** + +1. Each row's `(months, start)` equals the transition applied to its predecessor — client-verifiable per row. +2. `months ≥ 0`; the sum of `delta` equals `months`; `start` is non-decreasing. +3. `consume` rows ↔ period issuances are 1:1 (`issuances.entry_id`) — written in one transaction, after the credential is signed. +4. Re-running the `issue` op (§4) immediately appends nothing; the response is the cached credential or `status`. + +**Example** — buy 3 months Tue Mar 10, 2026; app off Apr 5 – May 20; issue May 20: + +| # | op | delta | months | start | note | +|---|---|---|---|---|---| +| 1 | grant(payment) | +3 | 3 | Mar 10 | paidThrough = Jun 10 | +| 2 | consume | −1 | 2 | Apr 10 | issuance: Mar 10–Apr 10, expiry Sun Apr 12 | +| 3 | lapse | −1 | 1 | May 10 | Apr 10–May 10 passed unissued; recorded May 20 | +| 4 | consume | −1 | 0 | Jun 10 | issuance: May 10–Jun 10, expiry Sun Jun 14 | + +`paidThrough` after every row = Jun 10. ~Jun 7: prepaid-ending alert. Jun 10: sender-side perk disabled. Sun Jun 14: credential expires. Mon Jun 15: removal update. An additional +3 purchase on Jun 5: `months = 0` → row (grant, +3, 3, max(Jun 10, Jun 5)) → paidThrough = Sep 10. + +**Adjustments:** + +| Case | Rows | +|---|---| +| refund / chargeback (Apple `REFUND` notification; Google voided purchases; Stripe disputes) | `debit(refund)`; issued credentials remain valid to expiry | +| goodwill / outage compensation | `grant(goodwill) +N` with reason text | +| upgrade conversion (2.10) | `debit(conversion)` on the supporter badge; discount on the legend invoice | +| overpaid / duplicate crypto invoice | `grant(goodwill) +N` or refund, per support resolution | +| balance transfer after profile loss | `debit(transfer_out)` old badge / `grant(transfer_in) +M` new badge | +| leaked/abused code batch | `debit(correction)` on affected badges | + +### Recovery and transfer + +- Normal path: `order_priv_key` and `master_key` are part of the profile backup; after restore, a signed `status` request resolves the same order. +- Receipts (Stripe and crypto payments): the bot creates a receipt code for every settled payment — a high-entropy, checksummed, human-copyable bearer secret; only its hash is stored bot-side; redeemable once. Contained in the issue response; stored in the app (payment history, profile backups); save prompt at checkout (2.1). +- Stores: no receipt — the new client presents a fresh store receipt → re-bind, capped per payment and period. +- Transfer: a request signed by the new order key contains the receipt → `debit(transfer_out)` on the old badge, `grant(transfer_in)` on the new; the provider binding is reassigned to the new order. Once per receipt; unissued months only; logged. With a receipt used by another party, only unissued months are transferred, once; disputes are sent to support. +- Support evidence when the receipt is lost: the statement-descriptor ref (~22 chars total incl. prefix, e.g. `SIMPLEX 4F7K2`) and, for crypto, txid + amount/time. Discretionary; sufficient only for cancellation-level actions. +- Both app and receipt lost: no recovery. + +### `badges` — one row per order + +- `badge_id` PK +- `user_id` +- `order_key` — unique; the order identity +- `order_priv_key`, `master_key` +- `badge_type` +- `product_id` → products +- `payment_id` → payments — NOT NULL; the current payment. The badge row and its first payment are created in one transaction +- `status` — `acquiring|issued|superseded|failed` +- credential columns — `key_idx`, `signature`, `badge_expiry` (`BadgeRow` conventions; investor: `badge_expiry` NULL) +- `use_from` — presentation start (2.5) +- `paused_at` (2.13) +- `alert_acked_kind` + `alert_acked_episode`, `alert_snooze_until` (2.4) +- `created_at`, `updated_at` + +- A row is created per manual act (purchase, upgrade, resubscribe, redeem); on subscription renewal and prepaid re-issue the credential is updated in place, with charge and ledger rows added. +- Status is derived at load (`mkBadgeStatus`); the current alert is derived (2.4). Months and `paidThrough`: last ledger row. `badge_expiry`: credential columns / last issuance. +- Get-or-create concurrency: per-user lock (controller `TMap`) + one store transaction + partial unique index — one live (`acquiring|issued`) row per (`user_id`, slot), slot = investor vs paid. +- The shown badge is referenced from the user's record (`users.shown_badge_id`) — at most one, structurally; presentation starts at `use_from`; on a switch the client updates the reference and `contact_profiles` via `setUserBadge`. + +### Storage integration + +`contact_profiles` badge columns remain the presentation copy of the shown badge (`setUserBadge`, Store/Profiles.hs:375). `badges` records what the user has; the profile columns record what the user shows (2.6.8). + +## 4. Wire protocol + +`docs/protocol/badges-rpc.schema.json` + `docs/protocol/badges-rpc.md`. Every request except `catalog` is signed with the order key and contains `orderKey`, its public part; the bot verifies `orderKey` equals the transport signer key. + +| op | request | response | +|---|---|---| +| `catalog` | — | catalog | +| `order` (stripe/btc/xmr) | `offerId`, `provider` | invoice: url/address, amount, expiry | +| `issue` | `masterKey`; store evidence (JWS / token) when settling a store payment; `since` | credential + sync, or status | +| `status` | `since` | status or credential + sync | +| `redeem` | `masterKey`, `code`, `since` | credential + sync | +| `pause` / `resume` (post-MVP, 2.13) | — | status | +| `transfer` (post-MVP, §3 recovery) | `receipt` | status | + +- Offer resolution: by `offerId` at `order`; by the store SKU in the evidence at `issue`; by the code at `redeem`. The order's product is pinned at creation. +- On `issue`: `advance(now)`; then: current month issued → cached credential (O12); `issue` precondition met → `issue(now)`; otherwise `status` (§3). +- Store cancellation is performed in store UI and reflected by the next `status` (§6.11). +- New product types are added in the catalog (`product_type`); the ops are unchanged. + +## 5. Providers + +| | invoice step | pending means | settled evidence | renewal | cancel | +|---|---|---|---|---|---| +| Apple | local: `Product.purchase()` after creating the payment row | `pending` (Ask to Buy / SCA), late result via `Transaction.updates` | signed transaction JWS (verified offline by the bot; contains storefront price + currency) | store auto-renews; issue after `renews_at`; history = Get Transaction History | store management sheet | +| Google | local: `launchBillingFlow` | `purchaseState = PENDING` | purchase token (verified + acknowledged by the bot ≤ 3 days) | store auto-renews; per-renewal order ids | Play subscriptions UI | +| Stripe | RPC `order` → invoice (payment link) | `issue` responds "pending" until the webhook is received | none client-side — signer key + bot webhook state | none — prepaid 1/3/12 months; renewed manually | n/a | +| BTCPay (btc/xmr) | RPC `order` → invoice (address/amount), rate fixed, window ~15–60 min | invoice `Processing` | none client-side — invoice `Settled` at the bot | none — prepaid 1/3/12 months; renewed manually | n/a | + +## 6. Decisions + +Resolved: + +1. Banner thresholds — set at implementation. +2. Alerts — one derived alert per profile; two markers on the badge row; no alerts table. +3. Reminders — 3 days before, once; "Remind me again" 1 day before; opt-out covers reminder kinds only. +4. Start-sharing — immediate by default; "from Monday" toggle; interactive question only under "Show advanced privacy questions". +5. Prepaid upgrade pricing — deduct the full supporter price while the supporter badge is unexpired. +6. Charge history — last 12 in UI. +7. Redeem codes — all badge types; the service response contains type + months. +8. Crypto prices — fiat-first; crypto amount fixed at invoice time. +9. Time accounting — single-entry months ledger (§3); unconsumed months lapse; per-charge grants; pause post-MVP. +10. Receipts — every Stripe/crypto payment; recovery is the receipt's only operation; stores re-bind via the store account. +11. No card (Stripe) subscriptions — multi-month prepaid is the non-store model. Re-add path if ever needed: `cancel` op + bot webhook renewal handling; the schema suffices (`renews_at`/`cancelled`, per-charge grants, receipt re-bind, statement descriptor). +12. Durations and pricing — non-store 1/3/12 months at 1×/2×/6× monthly; store annual at 6× monthly; store offering = 1-month one-time + monthly/annual subscriptions, no store multi-month SKUs; all pricing in offers. +13. Expiry rounding — weekly: badges end Sundays (UTC); Monday presentations + removal updates; payment/issuance cadence stays monthly. +14. One user-facing date — paid-through everywhere; the sender's client disables perks at paid-through; credential expiry is internal. + +Open: + +1. User-picker badge presence: icon (preferred) vs row (2.3) — needs design. +2. Scope of "Show advanced privacy questions" beyond badges — separate initiative. +3. Support tooling at launch: refund clawback is mandatory; transfer is manual (support acts on a presented receipt) until the op is implemented. + +## 7. MVP + +Non-store payments at MVP are multi-month prepaid purchases: one flow (order → pay → issue → grant) for Stripe, BTC, XMR. Subscriptions exist only on stores. The ledger, charges, and receipts are included in the MVP. + +### Launch set + +- Tiers: supporter + legend + investor (redeem codes, 2.8). Store: monthly and annual subscriptions + one-time 1 month. Stripe/BTC/XMR: one-time 1/3/12 months (§6.12). +- Redeem codes for all badge types (2.8) — the reward mechanism; code generation is operator tooling, required at MVP. Held badges with switching (2.7) — a switch takes effect immediately at MVP (`use_from` deferred). +- Ops: `catalog` | `order` | `issue` | `status` | `redeem`. Receipts in issue responses; the full catalog in `issue`/`status` responses. +- Store cancellation: + - the Cancel button opens the store management sheet; the engine sends `status` on return; + - the client renders cancelled-active from local renewal state (StoreKit 2 `RenewalInfo.willAutoRenew`, Play Billing `Purchase.isAutoRenewing`); + - the bot reads cancellation from the provider on every `issue`/`status` (App Store Server API `autoRenewStatus`, Play `subscriptionsv2`) and from store notifications. +- Bot-side provider notifications, required: + - Stripe webhooks + - BTCPay webhooks + - App Store Server Notifications V2 + - Play RTDN + - covering: cancellations, grace/on-hold, refunds/voided purchases + - On a notification the bot updates its records and re-reads provider state; credentials are never issued from a notification payload; nothing is pushed to the client. +- Tables (§3 ownership): + - `products`, `offers` + - `payments` + - `charges` + - `badge_ledger` + - `issuances` + - `badges` — without `use_from`, `paused_at` +- Engine per 2.9: issue requests, monthly re-issue, `advance`/`issue` recording, Monday presentation incl. removal updates, alert derivation. +- Alerts: the full 2.4 set (renewal approaching: store subscriptions only); opt-out toggle. +- Receipts for every Stripe/crypto payment with the checkout save prompt; `transfer` op deferred — support executes recovery manually against a presented receipt. +- Entry points: chat-list banner, Settings "Supporter perks" row, whats-new. Management screen without the Paused state; `charges` recorded from the first release, history UI deferred. +- New designs: + - management screen + - method + duration selector + - crypto invoice screen +- Multi-profile; incognito profiles never present a badge. + +### Required in the first release (or earlier) + +1. +7 day recipient display grace (2.11) — in the earliest release, before purchases. +2. Week-boundary expiry + Monday presentation incl. removal updates (2.11). +3. Order-key-per-badge identity (§4). +4. Apple subscription group containing all subscription SKUs (both tiers × monthly/annual). +5. Refund handling: stop issuance + `debit(refund)`. +6. Stripe statement descriptor with short payment ref. +7. Receipts from the first payment. + +### Deferred + +| Deferred | Returns | Re-add path | +|---|---|---| +| `transfer` op | phase 2 | receipts exist from the first release; support transfers manually until then | +| pause / resume | phase 2 | ledger `resume` transition + `paused_at` column | +| charge history UI | phase 2 | `charges` recorded from the first release | +| upgrades (2.10) | phase 2 | store subscription group in place (item 4 above); bot SKU-change mapping + conversion debits | +| start-sharing question + "Show advanced privacy questions" (`use_from`) | phase 3 | immediate presentation and switching at MVP | +| user-picker badge presence | phase 2 | the Settings row is the entry point until then | diff --git a/plans/2026-07-31-badges-core-implementation.md b/plans/2026-07-31-badges-core-implementation.md new file mode 100644 index 0000000000..4928522b97 --- /dev/null +++ b/plans/2026-07-31-badges-core-implementation.md @@ -0,0 +1,410 @@ +# Supporter Badges v3 — core implementation plan + +**Date:** 2026-07-31 +**Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` (referenced below as UX §n) +**Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`) +**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge | transferBadge` are defined in the protocol, post-MVP; no `use_from`, `paused_at`. + +## 1. Client schema + +`src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs` — SQLite only; the Postgres variant is written when the schema is final; registered in the migrations list and cabal at delivery step 2. + +- Table mapping to UX §3: + - `badges` → `badge_purchases` + - `issuances` → `badge_issuances` + - `products` + `offers` → `badge_prices` + `badge_offers` + - `badge_ledger` → `badge_ledger` + - `payments` and `charges` — unprefixed, product-agnostic (UX §3 ownership) +- The payment row is inserted first, the badge row in the same transaction. +- Payment columns beyond the UX §3 list (`invoice_*` client-side; `grace_until` and `exception` on both sides, sent in `paymentState`): + - `invoice_address` — the crypto screen address (UX 2.1) + - `invoice_crypto_amount` — the fixed crypto amount (UX 2.1) + - `invoice_expires_at` — the countdown and the expired state (UX 2.1) + - `grace_until` — the provider grace/hold deadline (UX 2.4 payment failed; 2.6 state 5) + - `exception` — the provider exception state: partial or over payment (UX 2.1) +- The badge–user match for `users.shown_badge_id` is enforced in code. +- `badge_ledger` has one definition — the `badgeLedgerTable` constant in the client migration; the service schema re-uses it literally. +- Settings (reminders opt-out; "Show new badges from Monday", post-MVP) are app settings, not schema. + +### `badge_ledger` + +A verbatim replica of the service ledger: the service is the only author; rows arrive in statements; the last row is the balance; each row is verifiable from its predecessor. + +| column | meaning | +|---|---| +| `entry_id` | local autoincrement; PK; never on the wire | +| `entry_uuid` | service-assigned UUIDv7; sent both ways; UNIQUE index | +| `badge_purchase_id` | FK → `badge_purchases` | +| `change_months` | months added or removed: `+3` credit, `−1` debit | +| `balance_months` | unused months after this entry | +| `balance_start_ts` | start of the unused balance, after this entry | +| `balance_badge_type` | the type of the unused balance, after this entry | +| `was_paused_since` | set on the entry ending a pause: paused from this time until this entry | +| `service_created_at` | service time of the entry | +| `created_at` | local time the row was stored | +| `entry_type` | `credit` \| `debit` | +| `entry_credit_type` | the `type` tag of the `credit` value — any string, known or not | +| `entry_debit_type` | the `type` tag of the `debit` value — any string, known or not | +| `entry_type_unknown` | client only, added via ALTER TABLE after the shared table: 1 when the tag is beyond the client's version; the typed reference columns stay NULL | +| `entry_type_value` | client only, added via ALTER TABLE: the credit/debit object JSON, verbatim; present only for unknown tags; re-decoded after upgrade | +| `invoice_id` | field of `credit(payment)`; FK → `payments` | +| `charge_id` | field of `credit(charge)`; FK → `charges` | +| `from_purchase_id` | field of `credit(transferIn)`; FK → `badge_purchases`, nullable — the source may not exist locally | +| `to_purchase_id` | field of `debit(upgrade)` / `debit(transferOut)`; FK → `badge_purchases` | + +| kind | type | meaning | when | +|---|---|---|---| +| credit | `payment` | months credited: a settled non-recurring payment — card, crypto, code, store one-time | MVP | +| credit | `charge` | months credited: one subscription charge, the first included | MVP | +| credit | `support` | months credited by support — compensation, gift | MVP | +| credit | `transferIn` | months arriving via transfer | post-MVP | +| credit | `opening` | balance restated: the ledger is reset to the stated amount, no relation to the previous row | MVP | +| debit | `refund` | balance removed: refund or chargeback | MVP | +| debit | `upgrade` | balance converted into an upgrade's credit | post-MVP | +| debit | `transferOut` | balance transferred out | post-MVP | +| debit | `support` | balance removed by support — leaked code batch | MVP | +| debit | `badge` | one month spent on the badge — a credential issued | MVP | +| debit | `lapse` | elapsed unissued months removed | MVP | + +``` +entry = credit — creditType: payment {invoiceId} | charge {chargeId} | support | transferIn {fromPurchaseKey} | opening + | debit — debitType: refund | upgrade {toPurchaseKey} | transferOut {toPurchaseKey} | support | badge | lapse +``` + +The protocol entry (`StatementEntry`, §4): `entryId`, `changeMonths`, `balanceMonths`, `balanceStartTs`, `balanceBadgeType`, `wasPausedSince`?, `createdAt`, `entryType` — the same sum with wire references. + +## 2. Badge RPC service schema + +`plans/2026-07-31-badges-service-schema.sql` — SQLite, like the client (the Postgres variant is written when the schema is final); the bot schema, extended with the purchase/ledger layer. + +## 3. Client types + +Domain types — `src/Simplex/Chat/Badges/Store.hs`. Records: + +- `BadgePurchase` +- `BadgePayment` +- `BadgeLedgerEntry` +- `BadgeCharge` +- `BadgeIssuance` +- `BadgeAlert` +- `UserBadgeState` + +Id newtypes: + +- `BadgePriceId` +- `BadgeOfferId` +- `InvoiceId` + +Enums: + +- `BadgePlan` +- `BadgeItemStatus` +- `BadgeProvider` +- `BadgePaymentStatus` +- `BadgePurchaseStatus` +- `BadgeAlertKind` + +Tagged sums: + +- `BadgePaymentMethod` — `apple` | `google` | `card {provider}` | `crypto {currency}`; the last two map to the wire `ServicePaymentMethod` +- `BadgePurchasePayment` — `apple {paymentId, jws}` | `google {paymentId, token}` | `code {code}`; the `APIPurchaseBadge` argument, mapped to the wire `ServicePayment` +- `OfferDiscount` — `discount_type` with `free_months` / `discount` (§2) +- `LedgerEntryType` — `entry_type` with `credit_type` / `debit_type` (§2) +- `LedgerCreditType` +- `LedgerDebitType` + +Reused from `Simplex.Chat.Badges`: + +- `BadgeType` +- `BadgeInfo` +- `BadgeRequest` +- `BadgeCredential` +- `BadgeMasterKey` + +Protocol types — `src/Simplex/Chat/Badges/Service.hs`, one constructor/field per JTD member: + +- `BadgeServiceRequest` +- `BadgeServiceCommand` +- `ServicePaymentMethod` +- `CardProvider` +- `CryptoCurrency` +- `CurrencyAmount` +- `ServicePayment` +- `BadgeUpgrade` +- `BadgeServiceResponse` +- `ServicePaymentDestination` +- `BadgeServiceErrorCode` +- `BadgeCatalog` +- `BadgePrice` — the store type as well +- `BadgeOffer` — the store type as well +- `BadgeStatement` +- `BadgeBalance` +- `StatementEntry` +- `StatementEntryType` +- `StatementCreditType` +- `StatementDebitType` + +Instances — added at implementation; enums follow the `BadgeType` conventions (Badges.hs:114); the unions encode the JTD discriminator `type`: + +- `TextEncoding` +- JSON +- `ToField` / `FromField` + +## 4. RPC protocol + +`docs/protocol/badges-rpc.schema.json` — the JTD schema (definitions `request` and `response`); `docs/protocol/badges-rpc.md` — the protocol description (identity, idempotency, op semantics, statement, errors). + +A request is an envelope: `version`; `purchaseKey`? (optional for `getBadgeCatalog` — signed, the response adds the purchase's `statement`; required for other commands, which are signed with it); `request` — the command, discriminated on `type`. Responses discriminate on `type`; response types are badge-namespaced, fields inside are plain. + +| request `type` | request fields (beyond `purchaseKey`, `version`) | response `type` | response fields | +|---|---|---|---| +| `getBadgeCatalog` (signature optional) | — | `badgeCatalog` | `catalog`
`badgeStatement`? (for signed requests) | +| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoiceId`
`badgeType`
`months`
`price`
`discount`?
`credit`?
`amount` (= price − discount − credit)
`currency`
`expiresAt`
`paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount` | +| `purchaseBadge` | `badgeRequest` — `masterKey`, `badgeInfo`
`payment` — `apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`
`receipt`? (not provided for lifetime badges)
`statement` | +| `upgradeBadgeSubscription` | `badgeRequest`
`payment` — `apple`: `jws`; `google`: `token`
`balance` | `badgeCredential` | `credential`?
`statement` | +| `issueBadge` | `badgeRequest`
`balance` | `badgeCredential` | `credential`? (absent when the balance is exhausted)
`statement` | +| `pauseBadge` (post-MVP) | — | `badgeCredential` | `credential`?
`statement` | +| `transferBadge` (post-MVP) | `badgeRequest`
`receipt` | `badgeCredential` | `credential`
`receipt`?
`statement` | +| any, on failure | — | `error` | `code` (incl. `payment_pending`, `code_invalid` / `code_used` / `code_expired`)
`message`?
`retryAfter`? | + +`statement` — record: `entries` — ledger entries; `previousEntryId`? — matches the client's asserted entryId, absent for the full ledger. + +`balance` — record: `lastEntry` — the client's last ledger entry. + +`catalog` — record: `prices`, `offers`. + +| item | fields | +|---|---| +| price | `priceId` — UUID
`badgeType` — `investor` is never priced
`monthPrice`
`currency` — `usd`
`status` — `active` \| `deprecated` \| `disabled`; disabled is not sent
`createdAt` | +| offer | `offerId` — UUID
`priceId`? — absent applies to any price
`months`
`discount` — `freeMonths`: `freeMonths` \| `discount`: `discount` (percent)
`status`
`createdAt` | + +Prices and offers have one type each across the protocol and the store: the UUID is the primary key, `createdAt` gives the sequence, and `payments` and `badge_purchases` reference them by UUID. + +Offer price: `freeMonths` → `(months − freeMonths) × monthPrice`; `discount` → `months × monthPrice × (100 − discount) / 100`, floored. + +Every offer states a discount; a plain 1-month purchase has no offer — its price is `monthPrice`, and `getBadgeInvoice` omits `offerId`. The duration selector is the fixed 1-month entry plus the offers. + +`getBadgeInvoice` pins the price by `priceId`, and the badge type and currency with it; the service validates `badgeInfo.badgeType` against the price. Repricing appends a price and deprecates the old one: a deprecated price is honored at invoice creation, a disabled one rejected. Deprecated prices and offers are sent so that a catalog refresh cannot remove what the client pinned; disabled ones are omitted, since the service rejects them. An offer pinned to a price dies with it; unpinned offers survive repricing. + +Rendering is app-driven: tiers and durations come from app resources, and one without a price is shown disabled. Store SKUs are absent from the catalog — store builds read prices from the store and SKUs from app config, and make no `getBadgeCatalog` requests, `issueBadge` on schedule and on wake keeping the ledger current. The service holds the SKUs for webhook mapping. + +## 5. Commands and events + +`ChatCommand` (Controller.hs; parsers in `chatCommandP`) — the UX 2.9 user actions. UX 2.9 actions without a command: + +- reminder and presentation toggles — app settings (§1) +- pause / resume and the start-sharing date — post-MVP (Scope) +- cancel — the Cancel button opens the store management sheet; on its close the management screen re-reads state via `APIGetBadgeState` (UX §7) + +```haskell +| APIGetBadgeState UserId -- /_badge state +| APIGetBadgeCatalog UserId -- /_badge catalog ; unsigned getBadgeCatalog +| APIGetBadgeInvoice {userId :: UserId, priceId :: BadgePriceId, offerId :: Maybe BadgeOfferId, paymentVia :: BadgePaymentMethod} -- /_badge invoice [] ; via — apple | google | card | crypto +| APIPurchaseBadge {userId :: UserId, payment :: BadgePurchasePayment} -- /_badge purchase +| APISwitchShownBadge {userId :: UserId, badgePurchaseId :: Int64} -- /_badge shown +| APIAckBadgeAlert {userId :: UserId, kind :: BadgeAlertKind, episode :: Text, snooze :: Bool} -- /_badge ack +``` + +| command | UX | called when | +|---|---|---| +| `APIGetBadgeState` | 2.2, 2.3, 2.6, 2.1, §7 | app start — the initial model load; a badge screen is opened or regains focus | +| `APIGetBadgeCatalog` | §3 prices | the purchase screen is opened (non-store builds); non-blocking | +| `APIGetBadgeInvoice` | 2.1 | the user taps Pay on the selected duration | +| `APIPurchaseBadge` | 2.1, 2.8, §5 | the store purchase flow returns evidence; the user submits a code (2.6.9) | +| `APISwitchShownBadge` | 2.6.8, 2.7 | the user selects the shown badge | +| `APIAckBadgeAlert` | 2.4 | the user taps OK or "Remind me again" on an alert | + +Purchase is two commands because the store purchase runs in the app between them: StoreKit and Play Billing are app-platform APIs, which core cannot call, and the store may deliver the result late (`pending` / Ask to Buy — via `Transaction.updates`, including after a restart). `APIGetBadgeInvoice` obtains the invoice for a card or crypto payment; after payment `APIPurchaseBadge` presents the store evidence, and the worker sends `purchaseBadge` — verification, the grant, and the first issuance in one round trip. Issuance has no command — `issueBadge` is core-driven (UX 2.9 engine), from the balance only: settlement credits months, issuance debits them (UX §3 ledger), so the first and every repeat issuance are the same command. + +- `APIGetBadgeInvoice` starts every purchase: core loads or creates the live purchase row for the badge type's slot (per-user lock + `idx_badge_purchases_live`) — a `failed` row of the same slot is reused — creates the payment row, and points the badge row's `payment_id` at it (UX §3: the current payment). For `card` and `crypto` core sends `getBadgeInvoice` with the pinned `priceId` and the `offerId` of the chosen duration, and responds with the invoice — the Stripe link or the crypto screen data (UX 2.1). For `apple` and `google` core writes the rows, generates the invoice id itself, and sends nothing, since prices come from the store and SKUs from app config (§4); the app states the store because core is the same on both platforms. The invoice fields are stored on the payment row (§1), so pending-payment screens re-render after a restart; after invoice expiry a new `APIGetBadgeInvoice` creates a new invoice and payment row (UX 2.1). +- The app passes the invoice id to the store as the account token — Apple `Product.PurchaseOption.appAccountToken` (a UUID, echoed in the signed transaction), Google `BillingFlowParams.setObfuscatedAccountId` — so the store transaction states which payment it settles, and the service reads it from the verified store payload at `purchaseBadge`. +- `APIPurchaseBadge` completes a store purchase — the only payment whose result is delivered to the app: the store hands the app the evidence, and only that evidence ties the store transaction to the purchase, because the store flow knows neither purchase keys nor the service. Core records it on the payment row; the worker sends `purchaseBadge` — the service verifies, records the credit, and issues in one round trip (§6). Card and crypto payments need no completion command and carry no evidence: the service records their settlement from the provider webhook (UX §7 notifications); the worker's next `issueBadge` returns the credential, or `payment_pending` until the webhook arrives (§4). +- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (`transferBadge`, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7). +- `APIGetBadgeState` loads the badge state into the app model at start (and on profile switch); events only update the model afterward, so without the initial read it would hold nothing at first render — the 2.2 banner is rendered from it. It reads stored state and sends nothing itself. The same call re-reads state when a badge screen is opened or regains focus, and signals the worker (§6); reconciliation results follow as `CEvtBadgeChanged`. Screen re-focus covers the returns that fire no core trigger: the store cancellation sheet close — UX §7 "the engine sends `status` on return"; the in-app sheet fires no foreground trigger — and return to a pending-payment screen after payment (UX 2.1), which on desktop produces no foreground event either. + +`ChatResponse`: + +```haskell +| CRBadgeState {user :: User, badgeState :: UserBadgeState} +| CRBadgeCatalog {user :: User, prices :: [BadgePrice], offers :: [BadgeOffer]} +| CRBadgeInvoice {user :: User, payment :: BadgePayment} +``` + +- `CRBadgeState` — the state the badge surfaces render (banner 2.2; picker 2.3; management screen 2.6); the response of every command except `APIGetBadgeInvoice` and `APIGetBadgeCatalog`. Worker results follow as `CEvtBadgeChanged`. +- `CRBadgeCatalog` — the refreshed catalog for the open purchase screen (UX §3 prices). +- `CRBadgeInvoice` — the purchase continuation (`APIGetBadgeInvoice`): `payment` holds the invoice fields — the Stripe link, or the crypto address, amount, and expiry (UX 2.1); for a store payment, the locally generated invoice id, which the app passes to the store as the account token. +- Errors: `ChatErrorType` gains `CEBadgeServiceError {badgeError :: BadgeServiceErrorCode, message :: Maybe Text, retryAfter :: Maybe Int}` — the inline code-redemption errors (UX 2.8) and the purchase-screen unavailable notice (UX 2.1). + +`ChatEvent`: + +```haskell +| CEvtBadgeChanged {user :: User, badgeState :: UserBadgeState} +| CEvtBadgeAlert {user :: User, alert :: BadgeAlert} +``` + +- `CEvtBadgeChanged` — emitted by the worker on any state change it applies (UX 2.9 events); open badge surfaces re-render from it. +- `CEvtBadgeAlert` — the derived current alert (UX 2.4); the app displays it and answers with `APIAckBadgeAlert`. + +The reminders opt-out is an app setting: core emits `CEvtBadgeAlert` for all kinds; the app suppresses display of the two reminder kinds (`BARenewalApproaching` and `BAPrepaidEnding`) when reminders are off, and ignores them in `UserBadgeState.alert` for the picker indicator (UX 2.3). + +## 6. BadgeManager + +Runs in core. Controller state; the worker is the agent `Worker` framework the controller already uses (`getAgentWorker` / `hasWorkToDo'` / `cancelWorker`; the `TMap k Worker` fields, Controller.hs:304): + +```haskell +data BadgeManager = BadgeManager + { badgeWorkers :: TMap UserId Worker, -- agent Worker: doWork TMVar, restart on crash + badgeLocks :: TMap UserId Lock, -- one signed badge op per user in flight + badgeReads :: TMap UserId UTCTime, -- read requests from APIGetBadgeState (step B) + badgeBoundaries :: TMap UserId UTCTime, -- next boundary per user + badgeTimerAsync :: TVar (Maybe (Async ())) -- the timer thread; sleeps to the earliest boundary + } +``` + +One worker per user because badge state is per profile (UX 2.12): the worker serializes that profile's RPC ops and row writes, and profiles do not delay one another. The worker holds no task queue: a trigger only signals it, and each pass derives the work from stored state (the reconcile step below). Ops are idempotent (§4), so lost and duplicated signals are harmless. + +Lifecycle: + +1. start — the first signal for a user creates the worker via `getAgentWorker`; the timer thread is started at chat start (`badgeTimerAsync`). +2. signal — every trigger performs `hasWorkToDo'`: the UX 2.9 triggers (chat start, foreground, and network restore signal every user with a badge or payment row; profile switch — the switched-to user), the timer thread, and the commands — `APIGetBadgeState`, which also records the request time in `badgeReads`; `APIPurchaseBadge` after the evidence is recorded, and again for presentation after a code (step E); `APISwitchShownBadge` for presentation. +3. pass — the loop takes the signal and runs the flowchart below once per live badge row (the paid and the investor slots, §5). +4. re-run — a signal that arrives during a pass stays in `doWork`; the loop runs one more pass, so state changed mid-pass is picked up. +5. idle — between passes the loop blocks on `doWork`. +6. stop — workers are stopped with `cancelWorker` at chat stop and on user deletion; the timer thread with them. + +The worker and the commands that send signed requests themselves (`APIGetBadgeInvoice` — `getBadgeInvoice`; `APIPurchaseBadge` with a code — `purchaseBadge`) take the user's lock in `badgeLocks`, so one signed command per user is in flight; the same lock guards get-or-create of the badge row (§5). The unsigned, purchase-independent `getBadgeCatalog` (`APIGetBadgeCatalog`) is sent outside the lock. `badgeLocks` follows the `withEntityLock` discipline (Library/Internal.hs:127) — `chatLock` is waited for first — so the step E broadcast creates no new lock order. + +There is no read command in the protocol: every response carries the `statement`, so state advances with the work. A purchase whose balance has lapsed sends nothing, and service-side credits (support, transfer-in post-MVP) would stay unseen; the signed `getBadgeCatalog` is the check — its response adds `badgeStatement` for the signing key, and it is the same request that prices a new purchase (§4). The pass reads the `badgeReads` entry of `APIGetBadgeState`: a request newer than the last response, a lapsed balance, and no due command → signed `getBadgeCatalog`, then the entry is cleared. It is also sent after a restore (UX §3 recovery). + +Timer — each pass reports the user's next boundary, the earliest of: + +- `renews_at` − 3d — renewal reminder (UX 2.4) +- paid-through − 3d — prepaid ending (UX 2.4) +- paid-through — ended alerts (UX 2.4); sender-side perk cutoff (UX 2.11) +- `alert_snooze_until` — the snoozed alert is emitted once more (UX 2.4) +- next Monday 00:00 UTC — presentation and removal updates (UX 2.11) + +Each pass writes its user's next boundary into `badgeBoundaries`; the timer thread sleeps until the earliest entry and signals the workers whose boundaries elapsed. + +```mermaid +flowchart TD + T[trigger: UX 2.9, timer, command] --> S[hasWorkToDo'] + S --> W{pass running?} + W -- yes --> H[signal held -> one more pass] + W -- no --> A[load badges + payments + last ledger rows] + A --> B[per live badge, reconcile:\nstore evidence unverified -> purchaseBadge\npayment in status new -> getBadgeInvoice re-sent\ninvoiced/pending non-store -> issueBadge\nsubscription past renews_at -> issueBadge\nbalance > 0, month unissued -> issueBadge\nlapsed balance, read requested -> signed getBadgeCatalog] + B --> C[request under the user lock, signed with the purchase key\non ASETimeout the same envelope is retried at the next signal] + C --> D[apply response:\ncatalog -> badge_prices / badge_offers\nstatement: entries -> badge_ledger verbatim, an opening entry restates the balance;\npayments and charges -> the local rows\nverify credential -> update badge_purchases credential columns\nerror: terminal code -> badge failed; transient -> hold the command until retryAfter elapses] + D --> E[presentation:\non/after Monday 00:00 UTC, or immediately on acquisition/switch:\nshown badge credential -> setUserBadge + broadcast, fresh proof per send, incognito connections skipped\nexpired unrenewed -> setUserBadge Nothing + broadcast\npaid expired + investor held -> switch shown to investor] + E --> F[derive alert from state\nif differs from acked/snoozed -> CEvtBadgeAlert] + F --> G[emit CEvtBadgeChanged if state changed\nwrite next boundary to badgeBoundaries] + G --> I[block on doWork] +``` + +Non-store purchase: + +```mermaid +sequenceDiagram + participant UI as app + participant C as core (BadgeManager) + participant B as bot + participant P as Stripe/BTCPay + UI->>C: APIGetBadgeInvoice priceId offerId card|crypto + C->>C: load or create the badge row; create the payment row (lock + unique index) + C->>B: getBadgeInvoice (signed; priceId, offerId, badgeInfo, paymentVia) + B->>P: create intent / invoice + B-->>C: badgeInvoice (invoiceId, price, discount, amount, expiresAt, paymentTo) + C-->>UI: CRBadgeInvoice (url / address, cryptoAmount, expiresAt) + UI->>UI: browser (card) or the in-app invoice screen (crypto); user pays + P-->>B: webhook: settled + B->>B: credit the ledger + UI->>C: APIGetBadgeState (the screen regains focus) + C->>B: issueBadge (signed; badgeRequest, balance) + B->>B: debit the month; issue + B-->>C: badgeCredential (credential, receipt, statement) + C->>C: store rows; verify credential; update badge; presentation + C-->>UI: CEvtBadgeChanged +``` + +Store purchase: + +```mermaid +sequenceDiagram + participant UI as app + participant S as StoreKit / Play Billing + participant C as core (BadgeManager) + participant B as bot + participant PR as store server API + UI->>C: APIGetBadgeInvoice priceId offerId apple|google + C->>C: load or create the badge row; create the payment row with a local invoice id (lock + unique index) + C-->>UI: CRBadgeInvoice (invoice id) + UI->>S: purchase(sku from app config, accountToken = invoice id) + S-->>UI: evidence (JWS / purchase token) + UI->>C: APIPurchaseBadge paymentId evidence + C->>B: purchaseBadge (signed; badgeRequest, payment: apple jws / google token) + B->>B: verify the JWS offline (Apple) + B->>PR: verify + acknowledge the purchase token (Google) + B->>B: credit the ledger; debit the month; issue + B-->>C: badgeCredential (credential, receipt, statement) + C->>C: store rows; verify credential; update badge; presentation + C-->>UI: CEvtBadgeChanged +``` + +Badge row status: + +```mermaid +stateDiagram-v2 + [*] --> acquiring: APIGetBadgeInvoice + [*] --> issued: APIPurchaseBadge with a code, created on success + acquiring --> issued: credential verified and stored + acquiring --> superseded: a new row takes the slot + acquiring --> failed: terminal error (step D) + issued --> issued: renewal / re-issue (credential updated in place) + issued --> superseded: a new row takes the slot (a code; upgrade post-MVP) + failed --> acquiring: get-or-create reuses the slot's failed row; payment_id repointed +``` + +## 7. UX coverage + +Each UX plan point and its implementation home: + +| UX | implementation | +|---|---| +| 2.1 method + duration selector, prices | app UI; `APIGetBadgeCatalog`; `badge_prices` / `badge_offers` (§1); the 1-month entry is priced by `monthPrice` (§4) | +| 2.1 unavailable options | a tier or duration with no price is shown disabled (§4); `offer_disabled` / `product_unavailable` | +| 2.1 crypto and Stripe screens | `APIGetBadgeInvoice` → `CRBadgeInvoice`; invoice columns (§1); payment statuses from the statement's payments; partial/over payment in `payments.exception` | +| 2.1 receipt save prompt | `receipt` in the `badgeCredential` response → `payments.receipt_code`; the prompt is app UI | +| 2.2 banner | app UI over `CRBadgeState` / `CEvtBadgeChanged` | +| 2.3 user picker | post-MVP (UX §7 Deferred); state and `alert` in place; the Settings row is the MVP entry point | +| 2.4 alerts | `BadgeAlertKind`; `alert_acked_kind` / `alert_acked_episode` / `alert_snooze_until` / `grace_until` (§1); worker step F; `CEvtBadgeAlert`; `APIAckBadgeAlert` | +| 2.5 start-sharing | post-MVP (`use_from`, Scope); immediate presentation in worker step E | +| 2.6 management screen | `UserBadgeState`; the commands table (§5) | +| 2.7 held badges, switching | `users.shown_badge_id`; `APISwitchShownBadge`; the investor fallback in step E | +| 2.8 redeem codes | `APIPurchaseBadge` with a code → `purchaseBadge`; payment `provider = code` | +| 2.9 triggers | worker lifecycle item 2 (§6) | +| 2.9 engine | the worker flowchart (§6) | +| 2.9 API calls | the commands table (§5) | +| 2.10 upgrades | post-MVP (UX §7 Deferred); the `superseded` status in place | +| 2.11 dates | bot `sundayAfter` (delivery 7); Monday in step E and the timer; `mkBadgeStatus` grace (delivery 1) | +| 2.12 multi-profile | one worker per user; the incognito skip in step E | +| 2.13 pause | post-MVP; `pauseBadge`, and `issueBadge` resumes (§4) | +| §3 ownership, tables | the §1 mapping; the service schema (§2) | +| §3 catalog rules | `badge_prices` / `badge_offers`; reconciliation (delivery 4) | +| §3 payments, charges | §1; the statement's payments and charges (§4) | +| §3 ledger, issuances | replicas (§1); the `balance` assertion and the `opening` restatement (§4); service transitions (delivery 7); tests (delivery 8) | +| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); `transferBadge` post-MVP | +| §4 wire protocol | `Badges/Service.hs`; `docs/protocol` | +| §5 providers | delivery 7; the §5 command bullets | +| §6 decisions 11–14 | 11 — `renews_at` / `cancelled` / charges kept; 12 — catalog seed (delivery 2); 13 — `sundayAfter`; 14 — `paidThrough` in `UserBadgeState` | +| §7 MVP set | Scope; the delivery order | + +## Delivery order + +1. `mkBadgeStatus`: the +7-day recipient display grace and the shifted `BSExpiredOld` boundary (UX 2.11) — released before purchases (UX §7). +2. Register migration `M20260731_user_badges` (migrations list + cabal + regenerated `chat_schema.sql` / `chat_lint.sql`); the Postgres variant of the migration; the catalog seed from app config (UX §3 prices); store functions (`Store/Badges.hs`): get-or-create with lock; last-ledger-row reads; verbatim replica inserts. +3. Instances for the types in `Badges.hs` and `Badges/Service.hs` (§3) + roundtrip tests. +4. RPC codec: `docs/protocol/badges-rpc.schema.json` packets ↔ `Badges/Service.hs` types; catalog reconciliation on every `badgeCatalog` response. +5. BadgeManager §6: worker, locks, timer; reconcile/apply/presentation/alert steps; events. +6. Commands §5 + parsers + `View.hs` rendering. +7. Bot: schema §2; ledger transitions (UX §3); providers (Apple offline JWS, Google verify+acknowledge, Stripe intents+webhooks, BTCPay invoices+webhooks); codes tooling; notifications endpoints; receipt generation and hashing on settlement (UX §3 recovery); the capped store re-bind on `purchase` (UX §3 recovery); store setup — one Apple subscription group for all subscription SKUs and the Stripe statement descriptor with the short payment ref (UX §7). +8. Tests: ledger properties 1–4 (UX §3); the `balance` assertion and the `opening` restatement; `purchaseBadge` and `issueBadge` idempotency; replica equality service↔client; price and offer lifecycle at `getBadgeInvoice`; alert derivation incl. supersession; Monday presentation incl. removal updates; provider sandbox flows. diff --git a/plans/2026-07-31-badges-service-schema.sql b/plans/2026-07-31-badges-service-schema.sql new file mode 100644 index 0000000000..4cbd872dea --- /dev/null +++ b/plans/2026-07-31-badges-service-schema.sql @@ -0,0 +1,146 @@ +CREATE TABLE badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES badge_prices, + months INTEGER NOT NULL, + discount_type TEXT NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE store_skus( + store_sku_id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + sku TEXT NOT NULL, + badge_type TEXT NOT NULL, + plan TEXT NOT NULL, + months INTEGER, + created_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_store_skus_provider_sku ON store_skus(provider, sku); + +CREATE TABLE badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_key BLOB NOT NULL, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(purchase_key) +); + +CREATE TABLE payments( + payment_id INTEGER PRIMARY KEY AUTOINCREMENT, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + invoice_uuid TEXT, + provider TEXT NOT NULL, + provider_ref TEXT, + months INTEGER, + amount INTEGER, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + receipt_hash BLOB, + renews_at TEXT, + grace_until TEXT, + cancelled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_payments_invoice_uuid ON payments(invoice_uuid); + +CREATE INDEX idx_payments_purchase ON payments(badge_purchase_id); + +CREATE INDEX idx_payments_provider_ref ON payments(provider, provider_ref); + +CREATE TABLE charges( + charge_id INTEGER PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES payments, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +-- badgeLedgerTable constant (M20260731_user_badges), re-used literally +CREATE TABLE badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT, + invoice_id INTEGER REFERENCES payments, + charge_id INTEGER REFERENCES charges, + from_purchase_id INTEGER REFERENCES badge_purchases, + to_purchase_id INTEGER REFERENCES badge_purchases +); + +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); + +CREATE INDEX idx_badge_ledger_purchase ON badge_ledger(badge_purchase_id, entry_id); + +CREATE INDEX idx_badge_ledger_invoice ON badge_ledger(invoice_id); + +CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); + +CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); + +CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); + +CREATE TABLE issuances( + issuance_id INTEGER PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases, + period_start TEXT, + period_end TEXT, + expiry TEXT, + entry_id INTEGER REFERENCES badge_ledger, + credential BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(badge_purchase_id, period_start) +); + +CREATE TABLE codes( + code_hash BLOB NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + months INTEGER, + batch TEXT NOT NULL, + redeemed_purchase_id INTEGER REFERENCES badge_purchases, + redeemed_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE provider_events( + provider TEXT NOT NULL, + event_id TEXT NOT NULL, + received_at TEXT NOT NULL, + processed_at TEXT, + PRIMARY KEY(provider, event_id) +); diff --git a/plans/2026-08-04-badges-mvp-scope.md b/plans/2026-08-04-badges-mvp-scope.md new file mode 100644 index 0000000000..f10e25f2cf --- /dev/null +++ b/plans/2026-08-04-badges-mvp-scope.md @@ -0,0 +1,83 @@ +# Supporter Badges v3 — MVP scope + +**Date:** 2026-08-04 +**Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` — UX §n +**Core plan:** `plans/2026-07-31-badges-core-implementation.md` — §n +**Protocol:** `docs/protocol/badges-rpc.md`, `docs/protocol/badges-rpc.schema.json` +**Service schema:** `plans/2026-07-31-badges-service-schema.sql` + +Order: one path end to end first, then the paths that need external systems. Each milestone ends with passing tests. + +## 1. Badge service + +`apps/simplex-badge-service` — a bot on core as a library, after `apps/simplex-directory-service`. + +- `BadgeService/Options.hs`, `Service.hs` — the RPC handler; `Store.hs`, `Store/SQLite/Migrations.hs` — the schema (§2), with `badgeLedgerTable` imported from the client migration. +- Request handling: envelope version check, signer key against `purchaseKey`, dispatch on the command. +- Ledger writes: one transaction per command; the statement is read back from the written entries. +- Credential signing: the badge key set and `sundayAfter` expiry (UX 2.11). +- Codes: hashes, batch, redemption (UX 2.8). +- HTTP listener for provider webhooks — `warp`, as in `tests/NameResolver.hs`, or simplexmq `getHTTP2Server`. Stripe signature verification reads the raw body. + +## 2. Core badge API + +§5 and §6, minus alerts. + +- Commands: `APIGetBadgeCatalog`, `APIGetBadgeInvoice`, `APIPurchaseBadge`, `APIGetBadgeState`, `APISwitchShownBadge`. +- Responses and events: `CRBadgeState`, `CRBadgeCatalog`, `CRBadgeInvoice`, `CEvtBadgeChanged`. +- Store functions (`Store/Badges.hs`): get-or-create under the user lock, last-entry reads, verbatim replica writes. +- `BadgeManager`: worker, locks, reconcile, apply, presentation. The timer and `CEvtBadgeAlert` follow later. +- JSON instances for `Badges/Service.hs` and `Badges/Store.hs` — `taggedObjectJSON`, with roundtrip tests. +- Register `M20260731_user_badges` in the migrations list and cabal; regenerate `chat_schema.sql` and `chat_lint.sql`. + +## 3. Store integration — iOS, Android + +- App Store Connect: badge products, one subscription group, sandbox testers. +- Play Console: SKUs, an internal track build, licence testers. +- Swift and Kotlin: read prices and SKUs, run the purchase with the invoice id as the account token (`appAccountToken`, `setObfuscatedAccountId`), pass the evidence to `APIPurchaseBadge`, handle late results (`Transaction.updates`, `queryPurchasesAsync`). +- Store SKUs are held in app config and in the service `store_skus` table. + +Console setup starts on day one — its latency is external. + +## 4. Provider integration + +Direct from the service; `http-client-tls` is available through simplexmq. + +| provider | outbound | inbound | +|---|---|---| +| Stripe | checkout session or payment intent | webhook: settled, refunded, subscription charge | +| BTCPay | invoice with address and amount | webhook: settled, partial, expired | +| Apple | — | server notifications v2 | +| Google | Publisher API verify and acknowledge | RTDN | + +Every inbound event is recorded in `provider_events` before processing. + +## 5. Tests + +`tests/Bots/BadgeTests.hs`, after `tests/Bots/DirectoryTests.hs`: the service in-process, chat clients as apps. + +- Provider mock — a Warp app on a free port with a mutable registry, after `tests/NameResolver.hs`: responses and webhook delivery are driven by the test. +- Coverage: code redemption; invoice, settlement, issuance; store evidence; renewal charges; replica equality service to client; idempotency of every command; price and offer lifecycle at `getBadgeInvoice`. + +## 6. Ledger algebra + +After the end-to-end path works. + +- Every entry type against every prior state; the resulting balance, its start, and its badge type. +- Properties: the balance never falls below zero; an `opening` entry restates it absolutely; issuance debits exactly one month; lapse debits only elapsed unissued months. +- The client's balance function against the service ledger, over generated histories. + +## Milestones + +| # | end state | needs | +|---|---|---| +| 1 | code redemption end to end: `purchaseBadge` with a code returns a credential, the client stores the replica and shows the badge | §1 service skeleton, §2 core API, §5 harness | +| 2 | card and crypto: invoice, mocked settlement, issuance, renewal reminder date | §4 Stripe and BTCPay, webhook listener | +| 3 | store purchase on both platforms | §3, console setup | +| 4 | subscriptions: renewal charges, cancellation, grace | charges wire identifier, §4 notifications | +| 5 | ledger algebra, unit tested | §6 | + +## Settle before milestone 4 + +- The wire identifier for charges — the service's local integer cannot be resolved by the client. +- Ledger unification: whether entries reference invoices, charges, and purchases by UUID and key rather than by local id. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 51f2ac8e91..c5dcf6111e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -40,6 +40,8 @@ library Simplex.Chat.AppSettings Simplex.Chat.Badges Simplex.Chat.Badges.CLI + Simplex.Chat.Badges.Service + Simplex.Chat.Badges.Store Simplex.Chat.Names Simplex.Chat.Call Simplex.Chat.Controller diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs new file mode 100644 index 0000000000..70084e70dd --- /dev/null +++ b/src/Simplex/Chat/Badges/Service.hs @@ -0,0 +1,244 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE PatternSynonyms #-} + +module Simplex.Chat.Badges.Service + ( BadgeServiceRequest (..), + BadgeServiceCommand (..), + BadgeServiceVersion, + VersionBadgeService, + pattern VersionBadgeService, + ServicePaymentMethod (..), + CardProvider (..), + CryptoCurrency (..), + CurrencyAmount (..), + ServicePayment (..), + BadgeUpgrade (..), + BadgeServiceResponse (..), + ServicePaymentDestination (..), + BadgeServiceErrorCode (..), + BadgeCatalog (..), + BadgePrice (..), + BadgeOffer (..), + BadgeStatement (..), + BadgeBalance (..), + StatementEntry (..), + StatementEntryType (..), + StatementCreditType (..), + StatementDebitType (..), + ) where + +import qualified Data.Aeson as J +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word8, Word16, Word32) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Store +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Version (VersionScope) +import Simplex.Messaging.Version.Internal (Version (..)) + +data BadgeServiceVersion + +instance VersionScope BadgeServiceVersion + +type VersionBadgeService = Version BadgeServiceVersion + +pattern VersionBadgeService :: Word16 -> VersionBadgeService +pattern VersionBadgeService v = Version v + +data BadgeServiceRequest = BadgeServiceRequest + { version :: VersionBadgeService, + purchaseKey :: Maybe C.PublicKeyEd25519, -- optional for BSCGetBadgeCatalog, required for other commands + request :: BadgeServiceCommand + } + +data BadgeServiceCommand + = BSCGetBadgeCatalog + | BSCGetBadgeInvoice + { priceId :: BadgePriceId, + offerId :: Maybe BadgeOfferId, -- absent for 1 month at the badge price + badgeInfo :: BadgeInfo, + paymentVia :: ServicePaymentMethod, + upgrade :: Maybe BadgeUpgrade -- upgrade non-store badge + } + | BSCPurchaseBadge + { badgeRequest :: BadgeRequest, + payment :: ServicePayment, + upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge + } + | BSCUpgradeBadgeSubscription + { badgeRequest :: BadgeRequest, + payment :: ServicePayment, -- store payments only + balance :: BadgeBalance + } + | BSCIssueBadge + { badgeRequest :: BadgeRequest, + balance :: BadgeBalance + } + | BSCPauseBadge + | BSCTransferBadge + { badgeRequest :: BadgeRequest, + receipt :: Text + } + +data ServicePaymentMethod + = SPMCard {provider :: CardProvider} + | SPMCrypto {currency :: CryptoCurrency} + deriving (Eq, Show) + +data CardProvider = CPStripe + deriving (Eq, Show) + +data CryptoCurrency = CCBtc | CCXmr + deriving (Eq, Show) + +-- USD etc. are in minor units, following Stripe etc. convention +newtype CurrencyAmount = CurrencyAmount Word32 + deriving (Eq, Show) + +data ServicePayment + = SPApple {jws :: Text} + | SPGoogle {token :: Text} + | SPInvoice {invoiceId :: InvoiceId} + | SPCode {code :: Text} + deriving (Show) + +data BadgeUpgrade = BadgeUpgrade + { fromPurchaseKey :: C.PublicKeyEd25519, + receipt :: Text, + receiptSignature :: C.Signature 'C.Ed25519, + balance :: BadgeBalance + } + +data BadgeServiceResponse + = BSPBadgeCatalog + { catalog :: BadgeCatalog, + badgeStatement :: Maybe BadgeStatement -- for signed getBadgeCatalog + } + | BSPBadgeInvoice + { invoiceId :: InvoiceId, + badgeType :: BadgeType, + months :: Word8, + price :: CurrencyAmount, + discount :: Maybe CurrencyAmount, -- discount amount from monthly price + credit :: Maybe CurrencyAmount, -- credit for upgrade + amount :: CurrencyAmount, + currency :: Text, + expiresAt :: UTCTime, + paymentTo :: ServicePaymentDestination + } + | BSPBadgeCredential + { credential :: Maybe BadgeCredential, -- Nothing when no balance to issueBadge or no current credential for pause + receipt :: Maybe Text, -- not provided for lifetime badges + statement :: BadgeStatement + } + | BSPError + { code :: BadgeServiceErrorCode, + message :: Maybe Text, + retryAfter :: Maybe Word32 + } + +data ServicePaymentDestination + = SPDCard + { provider :: CardProvider, + url :: Text + } + | SPDCrypto + { currency :: CryptoCurrency, + address :: Text, + cryptoAmount :: Text + } + deriving (Show) + +data BadgeCatalog = BadgeCatalog + { prices :: [BadgePrice], + offers :: [BadgeOffer] + } + deriving (Show) + +data BadgePrice = BadgePrice + { priceId :: BadgePriceId, + badgeType :: BadgeType, + monthPrice :: CurrencyAmount, + currency :: Text, + status :: BadgeItemStatus, + createdAt :: UTCTime + } + deriving (Show) + +data BadgeOffer = BadgeOffer + { offerId :: BadgeOfferId, + priceId :: Maybe BadgePriceId, -- absent applies to any price + months :: Word8, + discount :: OfferDiscount, + status :: BadgeItemStatus, + createdAt :: UTCTime + } + deriving (Show) + +data BadgeStatement = BadgeStatement + { entries :: [StatementEntry], + previousEntryId :: Maybe Text -- matches the client's asserted entryId, absent for the full ledger + } + deriving (Show) + +data BadgeBalance = BadgeBalance + { lastEntry :: StatementEntry + } + deriving (Show) + +data StatementEntry = StatementEntry + { entryId :: Text, + changeMonths :: Int, + balanceMonths :: Int, + balanceStartTs :: UTCTime, + balanceBadgeType :: BadgeType, + wasPausedSince :: Maybe UTCTime, + createdAt :: UTCTime, + entryType :: StatementEntryType + } + deriving (Show) + +data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {debit :: StatementDebitType} + deriving (Show) + +data StatementCreditType + = SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments + | SCCharge {chargeId :: Int64} + | SCSupport + | SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519} + | SCOpening + | SCUnknown {tag :: Text, json :: J.Object} + deriving (Show) + +data StatementDebitType + = SDRefund + | SDUpgrade {toPurchaseKey :: C.PublicKeyEd25519} + | SDTransferOut {toPurchaseKey :: C.PublicKeyEd25519} + | SDSupport + | SDBadge + | SDLapse + | SDUnknown {tag :: Text, json :: J.Object} + deriving (Show) + +data BadgeServiceErrorCode + = BSEBadRequest + | BSEUnsupportedVersion + | BSEUnknownPurchaseKey + | BSEUnknownOfferId + | BSEOfferDisabled + | BSEOfferMismatch + | BSEProductUnavailable + | BSEPaymentNotEntitled + | BSEPaymentPending + | BSEProviderUnavailable + | BSERateLimited + | BSECodeInvalid + | BSECodeUsed + | BSECodeExpired + | BSEReceiptInvalid + | BSEReceiptUsed + | BSEInternal + deriving (Eq, Show) diff --git a/src/Simplex/Chat/Badges/Store.hs b/src/Simplex/Chat/Badges/Store.hs new file mode 100644 index 0000000000..88357d1a6f --- /dev/null +++ b/src/Simplex/Chat/Badges/Store.hs @@ -0,0 +1,215 @@ +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + +module Simplex.Chat.Badges.Store + ( BadgePriceId (..), + BadgeOfferId (..), + InvoiceId (..), + BadgePlan (..), + BadgeItemStatus (..), + OfferDiscount (..), + BadgeProvider (..), + BadgePaymentStatus (..), + BadgePurchaseStatus (..), + LedgerEntryType (..), + LedgerCreditType (..), + LedgerDebitType (..), + BadgeAlertKind (..), + BadgePurchase (..), + BadgePayment (..), + BadgeLedgerEntry (..), + BadgeCharge (..), + BadgeIssuance (..), + BadgeAlert (..), + UserBadgeState (..), + ) where + +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word8) +import Simplex.Chat.Badges hiding (BadgePurchase (..)) +import Simplex.Messaging.Agent.Protocol (UserId) +import qualified Simplex.Messaging.Crypto as C + +-- confirmed +newtype BadgePriceId = BadgePriceId Text + deriving newtype (Eq, Show) + +-- confirmed +newtype BadgeOfferId = BadgeOfferId Text + deriving newtype (Eq, Show) + +-- to review +newtype InvoiceId = InvoiceId Text + deriving newtype (Eq, Show) + +-- unconfirmed draft +data BadgePlan = BPOneTime | BPMonthly | BPAnnual + deriving (Eq, Show) + +-- confirmed +data BadgeItemStatus = BISActive | BISDeprecated | BISDisabled -- disabled is not sent + deriving (Eq, Show) + +-- confirmed +data OfferDiscount + = ODFreeMonths {freeMonths :: Word8} + | ODDiscount {discount :: Word8} -- percent + deriving (Eq, Show) + +-- unconfirmed draft +data BadgeProvider = BPRApple | BPRGoogle | BPRStripe | BPRBtc | BPRXmr | BPRCode + deriving (Eq, Show) + +-- unconfirmed draft +data BadgePaymentStatus = BPSNew | BPSInvoiced | BPSPending | BPSSettled | BPSFailed | BPSExpired + deriving (Eq, Show) + +-- unconfirmed draft +data BadgePurchaseStatus = PSAcquiring | PSIssued | PSSuperseded | PSFailed + deriving (Eq, Show) + +-- confirmed +data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit :: LedgerDebitType} + deriving (Eq, Show) + +-- confirmed +data LedgerCreditType + = CTPayment {invoiceId :: Int64} + | CTCharge {chargeId :: Int64} + | CTSupport + | CTTransferIn {fromPurchaseId :: Maybe Int64} + | CTOpening + | CTUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +-- confirmed +data LedgerDebitType + = DTRefund + | DTUpgrade {toPurchaseId :: Int64} + | DTTransferOut {toPurchaseId :: Int64} + | DTSupport + | DTBadge + | DTLapse + | DTUnknown {tag :: Text, json :: J.Object} + deriving (Eq, Show) + +-- unconfirmed draft +data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded + deriving (Eq, Show) + +-- to review +data BadgePurchase = BadgePurchase + { badgePurchaseId :: Int64, + userId :: UserId, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType, + priceId :: Maybe BadgePriceId, + offerId :: Maybe BadgeOfferId, + paymentId :: Int64, + status :: BadgePurchaseStatus, + credential :: Maybe BadgeCredential, + alertAcked :: Maybe (BadgeAlertKind, Text), + alertSnoozeUntil :: Maybe UTCTime, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + +-- to review +data BadgePayment = BadgePayment + { paymentId :: Int64, + userId :: UserId, + purchaseKey :: C.PublicKeyEd25519, + badgeType :: BadgeType, + priceId :: Maybe BadgePriceId, + offerId :: Maybe BadgeOfferId, + invoiceUuid :: Maybe InvoiceId, + months :: Maybe Int, + amount :: Maybe Int64, + currency :: Maybe Text, + provider :: BadgeProvider, + providerRef :: Maybe Text, + invoiceUrl :: Maybe Text, + invoiceAddress :: Maybe Text, + invoiceCryptoAmount :: Maybe Text, + invoiceExpiresAt :: Maybe UTCTime, + evidence :: Maybe ByteString, + receiptCode :: Maybe Text, + status :: BadgePaymentStatus, + exception :: Maybe Text, + renewsAt :: Maybe UTCTime, + graceUntil :: Maybe UTCTime, + cancelled :: Bool, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Show) + +-- confirmed +data BadgeLedgerEntry = BadgeLedgerEntry + { entryId :: Int64, + entryUuid :: Text, + badgePurchaseId :: Int64, + changeMonths :: Int, + balanceMonths :: Int, + balanceStartTs :: UTCTime, + balanceBadgeType :: BadgeType, + wasPausedSince :: Maybe UTCTime, + serviceCreatedAt :: UTCTime, + createdAt :: UTCTime, + entryType :: LedgerEntryType + } + deriving (Show) + +-- unconfirmed draft +data BadgeCharge = BadgeCharge + { chargeId :: Int64, + paymentId :: Int64, + invoiceUuid :: InvoiceId, + providerChargeRef :: Text, + periodStart :: UTCTime, + periodEnd :: UTCTime, + amount :: Int64, + currency :: Text, + chargedAt :: UTCTime + } + deriving (Show) + +-- unconfirmed draft +data BadgeIssuance = BadgeIssuance + { issuanceId :: Int64, + badgePurchaseId :: Int64, + periodStart :: Maybe UTCTime, + periodEnd :: Maybe UTCTime, + expiry :: Maybe UTCTime, + entryId :: Maybe Int64, + createdAt :: UTCTime + } + deriving (Show) + +-- unconfirmed draft +data BadgeAlert = BadgeAlert + { kind :: BadgeAlertKind, + episode :: Text, + date :: UTCTime, + price :: Maybe (Int64, Text) + } + deriving (Show) + +-- unconfirmed draft +data UserBadgeState = UserBadgeState + { badges :: [BadgePurchase], + shownBadgeId :: Maybe Int64, + payments :: [BadgePayment], + monthsLeft :: Int, + paidThrough :: Maybe UTCTime, + renewsAt :: Maybe UTCTime, + willRenew :: Bool, + alert :: Maybe BadgeAlert + } diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs new file mode 100644 index 0000000000..08db8a49ca --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs @@ -0,0 +1,169 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260731_user_badges :: Query +m20260731_user_badges = + [sql| +CREATE TABLE badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES badge_prices, + months INTEGER NOT NULL, + discount_type TEXT NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE payments( + payment_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + purchase_key BLOB NOT NULL, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + invoice_uuid TEXT, + months INTEGER, + amount INTEGER, + currency TEXT, + provider TEXT NOT NULL, + provider_ref TEXT, + invoice_url TEXT, + invoice_address TEXT, + invoice_crypto_amount TEXT, + invoice_expires_at TEXT, + evidence BLOB, + receipt_code TEXT, + status TEXT NOT NULL, + exception TEXT, + renews_at TEXT, + grace_until TEXT, + cancelled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX idx_payments_purchase_key ON payments(purchase_key); + +CREATE UNIQUE INDEX idx_payments_invoice_uuid ON payments(invoice_uuid); + +CREATE TABLE charges( + charge_id INTEGER PRIMARY KEY, + payment_id INTEGER NOT NULL REFERENCES payments, + invoice_uuid TEXT NOT NULL, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +CREATE TABLE badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + badge_type TEXT NOT NULL, + price_id TEXT REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + payment_id INTEGER NOT NULL REFERENCES payments, + status TEXT NOT NULL, + badge_key_idx INTEGER, + badge_signature BLOB, + badge_expiry TEXT, + alert_acked_kind TEXT, + alert_acked_episode TEXT, + alert_snooze_until TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(purchase_key) +); + +CREATE UNIQUE INDEX idx_badge_purchases_live ON badge_purchases(user_id, (badge_type = 'investor')) + WHERE status IN ('acquiring', 'issued'); + +ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; +|] + <> badgeLedgerTable + <> [sql| +ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; + +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); + +CREATE INDEX idx_badge_ledger_purchase ON badge_ledger(badge_purchase_id, entry_id); + +CREATE INDEX idx_badge_ledger_invoice ON badge_ledger(invoice_id); + +CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); + +CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); + +CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); + +CREATE TABLE badge_issuances( + issuance_id INTEGER PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + period_start TEXT, + period_end TEXT, + expiry TEXT, + entry_id INTEGER REFERENCES badge_ledger, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_badge_issuances_purchase ON badge_issuances(badge_purchase_id, issuance_id); +|] + +badgeLedgerTable :: Query +badgeLedgerTable = + [sql| +CREATE TABLE badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT, + invoice_id INTEGER REFERENCES payments, + charge_id INTEGER REFERENCES charges, + from_purchase_id INTEGER REFERENCES badge_purchases, + to_purchase_id INTEGER REFERENCES badge_purchases +); +|] + +down_m20260731_user_badges :: Query +down_m20260731_user_badges = + [sql| +DROP TABLE badge_issuances; +DROP TABLE badge_ledger; +ALTER TABLE users DROP COLUMN shown_badge_id; +DROP TABLE badge_purchases; +DROP TABLE charges; +DROP TABLE payments; +DROP TABLE badge_offers; +DROP TABLE badge_prices; +|] From 7ce2e6583e7707de22fbdaeae74ebef1b5b34e74 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 8 Aug 2026 20:00:23 +0000 Subject: [PATCH 02/16] core: simplex-badge-service scaffolding (#7353) * core: simplex-badge-service scaffolding * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * clean-up --------- Co-authored-by: Evgeny Poberezkin --- .github/workflows/build.yml | 1 + apps/simplex-badge-service/Main.hs | 14 ++ apps/simplex-badge-service/README.md | 30 +++++ .../src/BadgeService/Options.hs | 93 ++++++++++++++ .../src/BadgeService/Service.hs | 120 ++++++++++++++++++ .../src/BadgeService/Store/Migrate.hs | 40 ++++++ .../BadgeService/Store/Postgres/Migrations.hs | 37 ++++++ .../BadgeService/Store/SQLite/Migrations.hs | 34 +++++ .../src/Directory/Service.hs | 2 +- docs/protocol/badges-rpc.schema.json | 10 +- simplex-chat.cabal | 53 ++++++++ src/Simplex/Chat/Badges/Service.hs | 52 ++++++++ src/Simplex/Chat/Bot.hs | 12 +- tests/Bots/BadgeServiceTests.hs | 81 ++++++++++++ tests/Test.hs | 2 + 15 files changed, 567 insertions(+), 14 deletions(-) create mode 100644 apps/simplex-badge-service/Main.hs create mode 100644 apps/simplex-badge-service/README.md create mode 100644 apps/simplex-badge-service/src/BadgeService/Options.hs create mode 100644 apps/simplex-badge-service/src/BadgeService/Service.hs create mode 100644 apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs create mode 100644 apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs create mode 100644 apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs create mode 100644 tests/Bots/BadgeServiceTests.hs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e5050fe8f..6cc939f866 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,6 +15,7 @@ on: - "apps/simplex-chat/**" - "apps/simplex-bot/**" - "apps/simplex-bot-advanced/**" + - "apps/simplex-badge-service/**" - "apps/simplex-broadcast-bot/**" - "apps/simplex-directory-service/**" - "tests/**" diff --git a/apps/simplex-badge-service/Main.hs b/apps/simplex-badge-service/Main.hs new file mode 100644 index 0000000000..b870ea8671 --- /dev/null +++ b/apps/simplex-badge-service/Main.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Main where + +import BadgeService.Options (BadgeServiceOpts (..)) +import BadgeService.Service +import Simplex.Chat.Terminal (terminalChatConfig) + +main :: IO () +main = do + opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts + if runCLI + then badgeServiceCLI opts + else badgeService opts terminalChatConfig diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md new file mode 100644 index 0000000000..2d3c2153f3 --- /dev/null +++ b/apps/simplex-badge-service/README.md @@ -0,0 +1,30 @@ +# SimpleX badge service + +Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is specified in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md), and the implementation plans live under [`plans/`](../../plans) (`2026-07-30-supporter-badges-v3-ux.md`, `2026-07-31-badges-core-implementation.md`, `2026-08-04-badges-mvp-scope.md`). + +At this stage the service: + +- creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)), +- listens for service requests (`CEvtServiceRequest`) on that address and responds to every command with `unsupported_version`, +- does not accept contact requests — the address is for RPC only, +- exposes a placeholder schema migration (`sx_badge_service_test`) and its own migrations table (`sx_badge_service_migrations`). + +Business logic — command dispatch, ledger writes, credential signing, provider webhooks — is left for follow-up per the plans. + +## Build + +Build prerequisites and the general contribution flow are in [`docs/CONTRIBUTING.md`](../../docs/CONTRIBUTING.md). + +``` +cabal build exe:simplex-badge-service +``` + +## Run + +``` +simplex-badge-service --help +``` + +- default (no `--run-cli`): background service mode, no interactive terminal. +- `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`). +- `--no-address`: skip address creation on start-up (for operators who provision the address themselves). diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs new file mode 100644 index 0000000000..82d82a454d --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -0,0 +1,93 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Options + ( BadgeServiceOpts (..), + getBadgeServiceOpts, + badgeServiceOpts, + mkChatOpts, + ) +where + +import qualified Data.Text as T +import Options.Applicative +import Simplex.Chat.Controller (updateStr, versionNumber, versionString) +import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) + +data BadgeServiceOpts = BadgeServiceOpts + { coreOptions :: CoreChatOpts, + serviceName :: T.Text, + clientService :: Bool, + noAddress :: Bool, + runCLI :: Bool, + testing :: Bool + } + +badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts +badgeServiceOpts appDir defaultDbName = do + coreOptions <- coreChatOptsP appDir defaultDbName + serviceName <- + strOption + ( long "service-name" + <> metavar "SERVICE_NAME" + <> help "The display name of the badge service bot, without *'s and spaces (SimpleX Badges)" + <> value "SimpleX Badges" + ) + clientService <- + switch + ( long "client-service" + <> help "Use client service certificate" + ) + noAddress <- + switch + ( long "no-address" + <> help "skip checking and creating service address" + ) + runCLI <- + switch + ( long "run-cli" + <> help "Run badge service as CLI" + ) + pure + BadgeServiceOpts + { coreOptions, + serviceName = T.pack serviceName, + clientService, + noAddress, + runCLI, + testing = False + } + +getBadgeServiceOpts :: FilePath -> FilePath -> IO BadgeServiceOpts +getBadgeServiceOpts appDir defaultDbName = + execParser $ + info + (helper <*> versionOption <*> badgeServiceOpts appDir defaultDbName) + (header versionStr <> fullDesc <> progDesc "Start SimpleX Badge Service with DB_FILE options") + where + versionStr = versionString versionNumber + versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") + versionAndUpdate = versionStr <> "\n" <> updateStr + +mkChatOpts :: BadgeServiceOpts -> ChatOpts +mkChatOpts BadgeServiceOpts {coreOptions, serviceName, clientService} = + ChatOpts + { coreOptions, + chatCmd = "", + chatCmdDelay = 3, + chatCmdLog = CCLNone, + chatServerPort = Nothing, + optFilesFolder = Nothing, + optTempDirectory = Nothing, + showReactions = False, + showFullLinks = False, + allowInstantFiles = True, + autoAcceptFileSize = 0, + muteNotifications = True, + markRead = False, + createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService}, + userDisplayName = Nothing, + userImageFile = Nothing + } diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs new file mode 100644 index 0000000000..64e934872e --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -0,0 +1,120 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Service + ( welcomeGetOpts, + badgeService, + badgeServiceCLI, + ) +where + +import BadgeService.Options +import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import Control.Concurrent.STM +import Control.Logger.Simple +import Control.Monad +import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import qualified Data.Text as T +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..)) +import Simplex.Chat.Bot (initializeBotAddress') +import Simplex.Chat.Controller +import Simplex.Chat.Core (sendChatCmd, simplexChatCore) +import Simplex.Chat.Options (printDbOpts) +import Simplex.Chat.Terminal (terminalChatConfig) +import Simplex.Chat.Terminal.Main (simplexChatCLI') +import Simplex.Chat.Types (AgentInvId (..), User (..)) +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) +import System.Directory (getAppUserDataDirectory) +import System.Exit (exitFailure) + +data ServiceState = ServiceState + { serviceCC :: TMVar ChatController, + serviceRequestQ :: TQueue (User, AgentInvId, J.Object) + } + +newServiceState :: IO ServiceState +newServiceState = do + serviceCC <- newEmptyTMVarIO + serviceRequestQ <- newTQueueIO + pure ServiceState {serviceCC, serviceRequestQ} + +welcomeGetOpts :: IO BadgeServiceOpts +welcomeGetOpts = do + appDir <- getAppUserDataDirectory "simplex" + opts@BadgeServiceOpts {coreOptions, testing, serviceName} <- getBadgeServiceOpts appDir "simplex_badge_service" + unless testing $ do + putStrLn $ "SimpleX Badge Service v" ++ versionNumber + printDbOpts coreOptions + putStrLn $ "Service name: " ++ T.unpack serviceName + pure opts + +badgeService :: BadgeServiceOpts -> ChatConfig -> IO () +badgeService opts cfg = do + env <- newServiceState + let chatHooks = + defaultChatHooks + { preStartHook = Just $ badgePreStartHook opts, + postStartHook = Just $ badgePostStartHook opts env + } + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> + forever $ do + (_, event) <- atomically . readTBQueue $ outputQ cc + case event of + -- TODO enforce _sigKey == BadgeServiceRequest.purchaseKey (docs/protocol/badges-rpc.md). + Right (CEvtServiceRequest u reqId _sigKey reqData) -> handleServiceRequest cc u reqId reqData + _ -> pure () + +badgeServiceCLI :: BadgeServiceOpts -> IO () +badgeServiceCLI opts = do + env <- newServiceState + let eventHook _cc ev = do + case ev of + Right (CEvtServiceRequest u reqId _sigKey reqData) -> + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, reqData) + _ -> pure () + pure ev + chatHooks = + defaultChatHooks + { preStartHook = Just $ badgePreStartHook opts, + postStartHook = Just $ badgePostStartHook opts env, + eventHook = Just eventHook + } + raceAny_ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processQueuedRequests env + ] + +processQueuedRequests :: ServiceState -> IO () +processQueuedRequests env = do + cc <- atomically $ readTMVar $ serviceCC env + forever $ do + (u, reqId, reqData) <- atomically $ readTQueue $ serviceRequestQ env + handleServiceRequest cc u reqId reqData + +badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () +badgePreStartHook opts ChatController {config, chatStore} = + runBadgeServiceMigrations opts config chatStore + +badgePostStartHook :: BadgeServiceOpts -> ServiceState -> ChatController -> IO () +badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do + -- SREQ delivery gates on this flag; Core starts serviceRequests=False, so the hook must set it. + atomically $ writeTVar (processServiceRequests cc) True + readTVarIO (currentUser cc) >>= \case + Nothing -> putStrLn "No current user" >> exitFailure + -- DR required for service RPC; autoAccept off because badge service ignores contact events. + Just _ -> do + unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc + void $ atomically $ tryPutTMVar (serviceCC env) cc + +handleServiceRequest :: ChatController -> User -> AgentInvId -> J.Object -> IO () +handleServiceRequest cc User {userId} reqId _reqData = do + let reqIdT = safeDecodeUtf8 (strEncode reqId) + respObj = KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEUnsupportedVersion)] + logInfo $ "badge service request " <> reqIdT + sendChatCmd cc (APISendServiceResponse userId reqId respObj) >>= \case + Right _ -> pure () + Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs b/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs new file mode 100644 index 0000000000..506043c262 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/Migrate.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module BadgeService.Store.Migrate + ( runBadgeServiceMigrations, + ) +where + +import BadgeService.Options +import Simplex.Chat.Controller (ChatConfig (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent.Store.Common +import Simplex.Messaging.Agent.Store.Interface (migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import System.Exit (exitFailure) + +#if defined(dbPostgres) +import BadgeService.Store.Postgres.Migrations +#else +import BadgeService.Store.SQLite.Migrations +#endif + +runBadgeServiceMigrations :: BadgeServiceOpts -> ChatConfig -> DBStore -> IO () +runBadgeServiceMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False []) + (Just "sx_badge_service_migrations") + badgeServiceSchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("badge service migrations " <>) . show) pure + where + BadgeServiceOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..a4bc3396bf --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +badgeServiceSchemaMigrations :: [Migration] +badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) + ] + +m20260806_badge_service_schema :: Text +m20260806_badge_service_schema = + T.pack + [r| +CREATE TABLE sx_badge_service_test( + test_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + |] + +down_m20260806_badge_service_schema :: Text +down_m20260806_badge_service_schema = + T.pack + [r| +DROP TABLE sx_badge_service_test; + |] diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..8404f135db --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -0,0 +1,34 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +badgeServiceSchemaMigrations :: [Migration] +badgeServiceSchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) + ] + +m20260806_badge_service_schema :: Query +m20260806_badge_service_schema = + [sql| +CREATE TABLE sx_badge_service_test( + test_id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT(datetime('now')) +); + |] + +down_m20260806_badge_service_schema :: Query +down_m20260806_badge_service_schema = + [sql| +DROP TABLE sx_badge_service_test; + |] diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7dc165c4df..0273740cbb 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -220,7 +220,7 @@ directoryPostStartHook opts@DirectoryOpts {noAddress, testing} env cc = readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just User {userId, profile = p@LocalProfile {preferences}} -> do - unless noAddress $ initializeBotAddress' (not testing) cc + unless noAddress $ initializeBotAddress' (not testing) Nothing True cc void $ atomically $ tryPutTMVar (serviceCC env) cc listingsUpdated env let cmds = fromMaybe [] $ preferences >>= commands_ diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index b1c3cf4a66..2caa3408e2 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -363,14 +363,8 @@ "error": { "properties": { "code": { - "enum": [ - "bad_request", "unsupported_version", "unknown_purchase_key", - "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", - "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", - "code_invalid", "code_used", "code_expired", - "receipt_invalid", "receipt_used", - "internal" - ] + "type": "string", + "metadata": {"comment": "BadgeServiceErrorCode"} } }, "optionalProperties": { diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c5dcf6111e..9f23bd9223 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -398,6 +398,52 @@ library , template-haskell ==2.16.* , text >=1.2.4.0 && <1.3 +executable simplex-badge-service + if flag(client_library) + buildable: False + main-is: Main.hs + hs-source-dirs: + apps/simplex-badge-service + apps/simplex-badge-service/src + default-extensions: + StrictData + other-modules: + BadgeService.Options + BadgeService.Service + BadgeService.Store.Migrate + Paths_simplex_chat + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts + build-depends: + aeson ==2.2.* + , base >=4.7 && <5 + , directory ==1.3.* + , optparse-applicative >=0.15 && <0.17 + , simple-logger ==0.1.* + , simplex-chat + , simplexmq >=6.3 + , stm ==2.5.* + default-language: Haskell2010 + if flag(client_postgres) + other-modules: + BadgeService.Store.Postgres.Migrations + build-depends: + postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* + cpp-options: -DdbPostgres + else + other-modules: + BadgeService.Store.SQLite.Migrations + build-depends: + sqlcipher-simple ==0.4.* + if impl(ghc >= 9.6.2) + build-depends: + bytestring ==0.11.* + , text >=2.0.1 && <2.2 + if impl(ghc < 9.6.2) + build-depends: + bytestring ==0.10.* + , text >=1.2.4.0 && <1.3 + executable simplex-bot if flag(client_library) buildable: False @@ -623,6 +669,10 @@ test-suite simplex-chat-test API.Docs.Syntax.Types API.Docs.Types API.TypeInfo + BadgeService.Options + BadgeService.Service + BadgeService.Store.Migrate + Bots.BadgeServiceTests Broadcast.Bot Broadcast.Options Directory.BlockedWords @@ -649,6 +699,7 @@ test-suite simplex-chat-test hs-source-dirs: bots/src tests + apps/simplex-badge-service/src apps/simplex-broadcast-bot/src apps/simplex-directory-service/src default-extensions: @@ -691,6 +742,7 @@ test-suite simplex-chat-test default-language: Haskell2010 if flag(client_postgres) other-modules: + BadgeService.Store.Postgres.Migrations Directory.Store.Postgres.Migrations build-depends: postgresql-simple ==0.7.* @@ -698,6 +750,7 @@ test-suite simplex-chat-test cpp-options: -DdbPostgres else other-modules: + BadgeService.Store.SQLite.Migrations Directory.Store.SQLite.Migrations build-depends: sqlcipher-simple ==0.4.* diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 70084e70dd..eb5a556503 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -1,5 +1,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} module Simplex.Chat.Badges.Service @@ -28,6 +30,7 @@ module Simplex.Chat.Badges.Service StatementDebitType (..), ) where +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import Data.Int (Int64) import Data.Text (Text) @@ -36,6 +39,7 @@ import Data.Word (Word8, Word16, Word32) import Simplex.Chat.Badges import Simplex.Chat.Badges.Store import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version (VersionScope) import Simplex.Messaging.Version.Internal (Version (..)) @@ -241,4 +245,52 @@ data BadgeServiceErrorCode | BSEReceiptInvalid | BSEReceiptUsed | BSEInternal + | BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients deriving (Eq, Show) + +instance TextEncoding BadgeServiceErrorCode where + textEncode = \case + BSEBadRequest -> "bad_request" + BSEUnsupportedVersion -> "unsupported_version" + BSEUnknownPurchaseKey -> "unknown_purchase_key" + BSEUnknownOfferId -> "unknown_offer_id" + BSEOfferDisabled -> "offer_disabled" + BSEOfferMismatch -> "offer_mismatch" + BSEProductUnavailable -> "product_unavailable" + BSEPaymentNotEntitled -> "payment_not_entitled" + BSEPaymentPending -> "payment_pending" + BSEProviderUnavailable -> "provider_unavailable" + BSERateLimited -> "rate_limited" + BSECodeInvalid -> "code_invalid" + BSECodeUsed -> "code_used" + BSECodeExpired -> "code_expired" + BSEReceiptInvalid -> "receipt_invalid" + BSEReceiptUsed -> "receipt_used" + BSEInternal -> "internal" + BSEUnknown t -> t + textDecode s = Just $ case s of + "bad_request" -> BSEBadRequest + "unsupported_version" -> BSEUnsupportedVersion + "unknown_purchase_key" -> BSEUnknownPurchaseKey + "unknown_offer_id" -> BSEUnknownOfferId + "offer_disabled" -> BSEOfferDisabled + "offer_mismatch" -> BSEOfferMismatch + "product_unavailable" -> BSEProductUnavailable + "payment_not_entitled" -> BSEPaymentNotEntitled + "payment_pending" -> BSEPaymentPending + "provider_unavailable" -> BSEProviderUnavailable + "rate_limited" -> BSERateLimited + "code_invalid" -> BSECodeInvalid + "code_used" -> BSECodeUsed + "code_expired" -> BSECodeExpired + "receipt_invalid" -> BSEReceiptInvalid + "receipt_used" -> BSEReceiptUsed + "internal" -> BSEInternal + t -> BSEUnknown t + +instance ToJSON BadgeServiceErrorCode where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeServiceErrorCode where + parseJSON = textParseJSON "BadgeServiceErrorCode" diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index dfb7418f3f..3b1c4c6da0 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -47,16 +47,17 @@ chatBotRepl welcome answer _user cc = do contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" initializeBotAddress :: ChatController -> IO () -initializeBotAddress = initializeBotAddress' True +initializeBotAddress = initializeBotAddress' True Nothing True -initializeBotAddress' :: Bool -> ChatController -> IO () -initializeBotAddress' logAddress cc = do +-- pqRatchet_ selects the address type when creating: Just True (IKUsePQ) is required for service RPC, Nothing is the legacy non-DR contact address. +initializeBotAddress' :: Bool -> Maybe Bool -> Bool -> ChatController -> IO () +initializeBotAddress' logAddress pqRatchet_ doAutoAccept cc = do sendChatCmd cc ShowMyAddress >>= \case Right (CRUserContactLink _ UserContactLink {connLinkContact}) -> showBotAddress connLinkContact Left (ChatErrorStore SEUserContactLinkNotFound) -> do when logAddress $ putStrLn "No bot address, creating..." -- TODO [short links] create short link by default - sendChatCmd cc (CreateMyAddress Nothing) >>= \case + sendChatCmd cc (CreateMyAddress pqRatchet_) >>= \case Right (CRUserContactLinkCreated _ ccLink) -> showBotAddress ccLink _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure @@ -65,7 +66,8 @@ initializeBotAddress' logAddress cc = do when logAddress $ do putStrLn $ "Bot's contact address is: " <> B.unpack (maybe (strEncode uri) strEncode shortUri) when (isJust shortUri) $ putStrLn $ "Full contact address for old clients: " <> B.unpack (strEncode uri) - let settings = AddressSettings {businessAddress = False, autoAccept = Just AutoAccept {acceptIncognito = False}, autoReply = Nothing} + let aa = if doAutoAccept then Just AutoAccept {acceptIncognito = False} else Nothing + settings = AddressSettings {businessAddress = False, autoAccept = aa, autoReply = Nothing} void $ sendChatCmd cc $ SetAddressSettings Nothing settings sendMessage :: ChatController -> Contact -> Text -> IO () diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs new file mode 100644 index 0000000000..896c603d7f --- /dev/null +++ b/tests/Bots/BadgeServiceTests.hs @@ -0,0 +1,81 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Bots.BadgeServiceTests where + +import BadgeService.Options +import BadgeService.Service +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Exception (finally) +import Simplex.Chat.Controller (ChatConfig) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) +import System.FilePath (()) +import Test.Hspec hiding (it) + +badgeServiceTests :: SpecWith TestParams +badgeServiceTests = do + it "should respond with unsupported_version to redeem" testBadgeServiceRedeemUnsupported + +badgeProfile :: Profile +badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} + +serviceDbPrefix :: FilePath +serviceDbPrefix = "badge_service" + +mkBadgeServiceOpts :: TestParams -> BadgeServiceOpts +mkBadgeServiceOpts TestParams {tmpPath = ps} = + BadgeServiceOpts + { coreOptions = + testCoreOpts + { dbOptions = + (dbOptions testCoreOpts) +#if defined(dbPostgres) + {dbSchemaPrefix = "client_" <> serviceDbPrefix} +#else + {dbFilePrefix = ps serviceDbPrefix} +#endif + }, + serviceName = "SimpleX Badges", + clientService = True, + noAddress = False, + runCLI = False, + testing = True + } + +withBadgeService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () +withBadgeService ps test = do + let opts = mkBadgeServiceOpts ps + withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () + -- First start: badge service takes the CreateMyAddress branch. + runBadgeService testCfg opts (pure ()) + -- Reopen the DB to read the link the service created. + bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do + bs <## "subscribed 1 connections on server localhost" + bs ##> "/sa" + (sLink, _) <- getContactLinks bs False + bs <## "auto_accept off" + pure sLink + -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. + runBadgeService testCfg opts $ + withNewTestChatCfg ps testCfg "client" bobProfile $ \client -> + test client bsLink + +runBadgeService :: ChatConfig -> BadgeServiceOpts -> IO () -> IO () +runBadgeService cfg opts action = do + t <- forkIO $ badgeService opts cfg + threadDelay 500000 + action `finally` killThread t + +testBadgeServiceRedeemUnsupported :: HasCallStack => TestParams -> IO () +testBadgeServiceRedeemUnsupported ps = + withBadgeService ps $ \client bsLink -> do + let redeemReq = + "{\"version\":1,\"request\":{\"type\":\"purchaseBadge\",\"payment\":{\"type\":\"code\",\"code\":\"TEST-CODE\"}}}" + client ##> ("/_service_request 1 " <> bsLink <> " " <> redeemReq) + client <## "service response: {\"code\":\"unsupported_version\",\"type\":\"error\"}" diff --git a/tests/Test.hs b/tests/Test.hs index 84a019c21c..6d0e299273 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,6 +3,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} +import Bots.BadgeServiceTests import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -88,6 +89,7 @@ main = do describe "SimpleX chat client" chatTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests + xdescribe'' "SimpleX Badge service bot" badgeServiceTests describe "Remote session" remoteTests #if !defined(dbPostgres) xdescribe'' "Save query plans" saveQueryPlans From cd670548b9e89e5fad276b40a735c1e728730e12 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:36:02 +0000 Subject: [PATCH 03/16] ui: badges WIP views (#7321) --- .../Views/Badges/BadgeUserPreview.swift | 39 ++++ .../Views/Badges/BadgesHowItWorksView.swift | 46 +++++ .../Shared/Views/Badges/BadgesPayView.swift | 146 ++++++++++++++ .../Views/Badges/BadgesRedeemCodeView.swift | 36 ++++ .../Badges/BadgesSupportSimplexView.swift | 165 +++++++++++++++ .../Views/Badges/BadgesYourLevelView.swift | 188 ++++++++++++++++++ .../Views/Badges/SupportSimpleXBanner.swift | 129 ++++++++++++ .../Shared/Views/ChatList/ChatListView.swift | 19 ++ apps/ios/Shared/Views/Helpers/NameBadge.swift | 2 +- .../Shared/Views/Onboarding/HowItWorks.swift | 2 + .../Views/Onboarding/WhatsNewView.swift | 48 +++++ .../Views/UserSettings/SettingsView.swift | 20 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 ++++ apps/ios/SimpleXChat/ChatTypes.swift | 11 + .../chat/simplex/common/model/SimpleXAPI.kt | 3 + .../common/views/badges/BadgeUserPreview.kt | 41 ++++ .../views/badges/BadgesHowItWorksView.kt | 34 ++++ .../common/views/badges/BadgesPayView.kt | 154 ++++++++++++++ .../views/badges/BadgesRedeemCodeView.kt | 29 +++ .../views/badges/BadgesSupportSimplexView.kt | 141 +++++++++++++ .../views/badges/BadgesYourLevelView.kt | 157 +++++++++++++++ .../views/badges/SupportSimpleXBanner.kt | 173 ++++++++++++++++ .../common/views/chatlist/ChatListView.kt | 28 ++- .../common/views/helpers/ChatInfoImage.kt | 2 +- .../common/views/onboarding/HowItWorks.kt | 10 +- .../common/views/onboarding/SimpleXInfo.kt | 6 +- .../common/views/onboarding/WhatsNewView.kt | 47 +++++ .../common/views/usersettings/SettingsView.kt | 12 ++ .../commonMain/resources/MR/base/strings.xml | 32 +++ .../default/MR/images/phone_supporter.svg | 4 + .../MR/images/phone_supporter_light.svg | 4 + 31 files changed, 1750 insertions(+), 14 deletions(-) create mode 100644 apps/ios/Shared/Views/Badges/BadgeUserPreview.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesPayView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift create mode 100644 apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift new file mode 100644 index 0000000000..3e7216866e --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -0,0 +1,39 @@ +// +// BadgeUserPreview.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 30.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgeUserPreview: View { + @EnvironmentObject var chatModel: ChatModel + let level: BadgeLevel + let trailing: () -> Trailing + + init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { + self.level = level + self.trailing = trailing + } + + var body: some View { + let user = chatModel.currentUser + let displayName = user?.displayName ?? NSLocalizedString("My nickname", comment: "badges preview placeholder") + let previewBadge = LocalBadge( + badge: BadgeInfo(badgeType: level.badgeType), + status: .active + ) + return VStack(spacing: 12) { + ProfileImage(imageStr: user?.image, size: 128) + HStack(alignment: .center, spacing: 6) { + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) + trailing() + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift new file mode 100644 index 0000000000..24836b37f9 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -0,0 +1,46 @@ +// +// BadgesHowItWorksView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. +struct BadgesHowItWorksView: View { + @EnvironmentObject var theme: AppTheme + + var body: some View { + VStack(alignment: .leading) { + Text("How private badges work") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + .padding(.bottom, 16) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") + Text("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.") + Text("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + } + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.horizontal, 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct BadgesHowItWorksView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesHowItWorksView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift new file mode 100644 index 0000000000..b32c14485f --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -0,0 +1,146 @@ +// +// BadgesPayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgePeriod: String, CaseIterable, Identifiable { + case oneMonth + case subscribe + + var id: String { rawValue } + + var icon: String { + switch self { + case .oneMonth: "calendar" + case .subscribe: "arrow.clockwise" + } + } + + var label: LocalizedStringKey { + switch self { + case .oneMonth: "1 month" + case .subscribe: "Subscribe" + } + } +} + +struct BadgesPayView: View { + @EnvironmentObject var theme: AppTheme + let level: BadgeLevel + @State private var selectedPeriod: BadgePeriod = .subscribe + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text(level.title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) + .padding(.top, 4) + + Text(level.tagline) + .font(.body) + .foregroundColor(theme.colors.onBackground) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + + Spacer(minLength: 20) + + HStack(alignment: .top, spacing: 12) { + periodCard(.oneMonth) + periodCard(.subscribe) + } + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func periodCard(_ period: BadgePeriod) -> some View { + let isSelected = period == selectedPeriod + return Button { + selectedPeriod = period + } label: { + VStack(spacing: 12) { + Image(systemName: period.icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + .padding(.top, 30) + Text(period.label) + .font(.title3) + .fontWeight(.bold) + .padding(.bottom, 30) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func payButton() -> some View { + Button { + // TODO [badges] wire to purchase API when it lands. + } label: { + Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } + + private var billingFooter: LocalizedStringKey { + // TODO [badges] source the actual date from the purchase state machine when wired. + var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 + let stubDate = Calendar.current.date(from: comps) ?? Date() + let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) + switch selectedPeriod { + case .subscribe: return "Renews on \(date). Cancel anytime." + case .oneMonth: return "Ends on \(date)." + } + } +} + +struct BadgesPayView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesPayView(level: .supporter) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift new file mode 100644 index 0000000000..2101bfa7f0 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -0,0 +1,36 @@ +// +// BadgesRedeemCodeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined. +struct BadgesRedeemCodeView: View { + @EnvironmentObject var theme: AppTheme + + var body: some View { + VStack(alignment: .leading) { + Text("Redeem badge code") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + Spacer() + } + .padding(.horizontal, 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct BadgesRedeemCodeView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesRedeemCodeView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift new file mode 100644 index 0000000000..6ae3176e85 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -0,0 +1,165 @@ +// +// BadgesSupportSimplexView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesSupportSimplexView: View { + @EnvironmentObject var theme: AppTheme + // set true when presented as a sheet root (from the chat-list banner) — that path doesn't + // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top + var showsAsSheet: Bool = false + @State private var whyBuiltActive = false + @State private var chooseLevelActive = false + @State private var redeemCodeActive = false + + var body: some View { + // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + GeometryReader { g in + VStack(alignment: .center, spacing: 16) { + Text("Support SimpleX") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("SimpleX doesn't sell ads or data. It's funded by its users and by investors who share the mission. You can support the project and show a badge on your profile.") + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + whyBuiltButton() + + Spacer(minLength: 0) + + PhoneSupporterHero() + .frame(maxWidth: g.size.width * 0.55) + .layoutPriority(-1) + + Spacer(minLength: 0) + + // Onboarding pattern: nested VStack(spacing: 10) + action button vertical padding 10. + VStack(spacing: 10) { + chooseLevelButton() + .padding(.vertical, 10) + redeemCodeButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + // .frame(height:) not minHeight — inside the banner sheet's NavigationView minHeight + // would let the VStack expand past the visible area and inflate the hero. + .frame(height: g.size.height) + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func whyBuiltButton() -> some View { + ZStack { + Button { whyBuiltActive = true } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("Why SimpleX is built.").fontWeight(.medium) + } + .font(.body) + } + NavigationLink(isActive: $whyBuiltActive) { + WhySimpleX(onboarding: false, titleColor: theme.colors.primary, createProfileNavLinkActive: .constant(false)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func chooseLevelButton() -> some View { + ZStack { + Button { + chooseLevelActive = true + } label: { + Text("Choose your level") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $chooseLevelActive) { + BadgesYourLevelView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func redeemCodeButton() -> some View { + ZStack { + Button { + redeemCodeActive = true + } label: { + Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + + NavigationLink(isActive: $redeemCodeActive) { + BadgesRedeemCodeView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct PhoneSupporterHero: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + + var body: some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 96) + } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .frame(maxWidth: .infinity) + #endif + } +} + +struct BadgesSupportSimplexView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesSupportSimplexView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift new file mode 100644 index 0000000000..3cfd576ea9 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -0,0 +1,188 @@ +// +// BadgesYourLevelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgeLevel: String, CaseIterable, Identifiable { + case supporter + case legend + + var id: String { rawValue } + + var title: LocalizedStringKey { + switch self { + case .supporter: "Supporter" + case .legend: "Legend" + } + } + + var filesDescription: LocalizedStringKey { + switch self { + case .supporter: "Send 2GB files" + case .legend: "Send 5GB files" + } + } + + var priceAmount: String { + switch self { + case .supporter: "$7" + case .legend: "$70" + } + } + + var tagline: LocalizedStringKey { + switch self { + case .supporter: "Optional profile badge\nand 2GB files" + case .legend: "Optional profile badge\nand 5GB files" + } + } + + var badgeType: BadgeType { + switch self { + case .supporter: .supporter + case .legend: .legend + } + } +} + +struct BadgesYourLevelView: View { + @EnvironmentObject var theme: AppTheme + @State private var selectedLevel: BadgeLevel = .supporter + @State private var continueActive = false + @State private var howItWorksActive = false + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Your level") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: selectedLevel) { + Image(systemName: "chevron.down") + .font(.body) + .foregroundColor(theme.colors.primary) + } + .padding(.top, 4) + + Spacer(minLength: 20) + + HStack(alignment: .top, spacing: 12) { + levelCard(.supporter) + levelCard(.legend) + } + + Spacer(minLength: 20) + + VStack(spacing: 10) { + continueButton() + .padding(.vertical, 10) + howItWorksButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func levelCard(_ level: BadgeLevel) -> some View { + let isSelected = level == selectedLevel + return Button { + selectedLevel = level + } label: { + VStack(spacing: 10) { + Image(badgeImageName(level.badgeType)) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .padding(.top, 20) + Text(level.title) + .font(.title3) + .fontWeight(.bold) + Text(level.filesDescription) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + Text("\(level.priceAmount)/month") + .font(.body) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesPayView(level: selectedLevel) + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func howItWorksButton() -> some View { + ZStack { + Button { + howItWorksActive = true + } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How private badges work").fontWeight(.medium) + } + .font(.body) + } + + NavigationLink(isActive: $howItWorksActive) { + BadgesHowItWorksView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct BadgesYourLevelView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesYourLevelView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift new file mode 100644 index 0000000000..07bf4366d2 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -0,0 +1,129 @@ +// +// SupportSimpleXBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SupportSimpleXBanner: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var showDismissAlert = false + let onTap: () -> Void + let onDismiss: () -> Void + + private let cardCornerRadius: CGFloat = 16 + // grows with Dynamic Type but never shrinks below the default so small-font users see the same + // banner as today; hero stays fixed so its above-card overhang shrinks at very large fonts + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + private let cardLeadingPadding: CGFloat = 16 + private let cardTrailingPadding: CGFloat = 8 + private let heroWidth: CGFloat = 110 + // shorter than the natural drawn height so .clipped() slices the phone body at card bottom + private let heroVisibleHeight: CGFloat = 108 + // hero right edge sits exactly at the dismiss X's left edge (X trailing 16 + width 12) + private let heroTrailingPadding: CGFloat = 28 + private let textToHeroGap: CGFloat = 6 + + var body: some View { + // Card is the Button; hero is an overlay so it can extend above the card top without + // affecting layout size. Dismiss X is a ZStack sibling anchored to the card's top-right. + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Support SimpleX") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Get badge + files up to 5GB") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap) + } + .padding(.leading, cardLeadingPadding) + .padding(.trailing, cardTrailingPadding) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: cardCornerRadius)) + } + .buttonStyle(.plain) + .overlay(alignment: .bottomTrailing) { + heroThumbnail() + .padding(.trailing, heroTrailingPadding) + .allowsHitTesting(false) + } + + Image(systemName: "multiply") + .foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.top, 12) + .padding(.bottom, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture { showDismissAlert = true } + } + .alert(isPresented: $showDismissAlert) { + Alert( + title: Text("Support SimpleX"), + message: Text("You can support SimpleX later in Settings."), + dismissButton: .default(Text("Ok"), action: onDismiss) + ) + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + // draws at natural aspect, top-aligned in a shorter slot; .clipped() cuts the overflow at card bottom + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: heroWidth, height: heroVisibleHeight, alignment: .top) + .clipped() + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .padding(.vertical, (cardHeight - 48) / 2) + .padding(.trailing, 12) + #endif + } + + private func gradientBackground() -> some View { + // Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is + // above, so the card's middle lands at the bright/mid-transition stop instead of the dark + // navy region. Keeps the small warm accent at top-right. + GeometryReader { geo in + let aspect = max(geo.size.height, 1) / max(geo.size.width, 1) + let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0 + let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1 + let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0) + let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale) + let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale) + return LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: start, + endPoint: end + ) + } + } +} + +struct SupportSimpleXBanner_Previews: PreviewProvider { + static var previews: some View { + SupportSimpleXBanner(onTap: {}, onDismiss: {}) + .padding() + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b05e0696e3..f8745a7eaf 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -174,7 +174,9 @@ struct ChatListView: View { @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @State private var showBadgesSheet = false // Spec: spec/client/chat-list.md#body var body: some View { @@ -211,6 +213,12 @@ struct ChatListView: View { NewChatSheet() .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } + .appSheet(isPresented: $showBadgesSheet) { + NavigationView { + BadgesSupportSimplexView(showsAsSheet: true) + .modifier(ThemedBackground()) + } + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -418,6 +426,17 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } + if !supporterBannerShown && chatModel.chats.count > 3 { + SupportSimpleXBanner( + onTap: { showBadgesSheet = true }, + onDismiss: { withAnimation { supporterBannerShown = true } } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat, parentSheet: $sheet) diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift index 67f6d6d6b2..11a4dafd2f 100644 --- a/apps/ios/Shared/Views/Helpers/NameBadge.swift +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -95,7 +95,7 @@ struct NameBadge: View { } } -private func badgeImageName(_ t: BadgeType) -> String { +func badgeImageName(_ t: BadgeType) -> String { switch t { case .legend: "badge-legend" case .investor: "badge-investor" diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index e9b9c6b970..dd01534dfb 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -65,6 +65,7 @@ struct WhySimpleX: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool + var titleColor: Color? = nil @Binding var createProfileNavLinkActive: Bool var body: some View { @@ -74,6 +75,7 @@ struct WhySimpleX: View { Text("You were born without an account") .font(.title) .bold() + .foregroundColor(titleColor) .padding(.top) Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.") Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.") diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 6ea24ec91c..9271566676 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -695,6 +695,18 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + // TODO [badges] finalise copy + Read more link before v7.1 ships. + VersionDescription( + version: "v7.1", + post: nil, + features: [ + .view(FeatureView( + icon: nil, + title: "Supporter badge", + view: { SupporterBadgeWhatsNew() } + )) + ] + ), ] private let lastVersion = versionDescriptions.last!.version @@ -709,6 +721,42 @@ func shouldShowWhatsNew() -> Bool { return v != lastVersion } +fileprivate struct SupporterBadgeWhatsNew: View { + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Supporter badge ❤️") + .font(.title3) + .bold() + Text("Help keep the network running — send files up to 2 GB.") + .multilineTextAlignment(.leading) + .lineLimit(10) + } + .frame(maxWidth: .infinity, alignment: .leading) + + heroThumbnail() + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(width: 110, height: 110) + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + #endif + } +} + fileprivate struct NewOperatorsView: View { var body: some View { VStack(alignment: .leading) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4bd5f7db1b..012142d9ba 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -56,6 +56,7 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" +let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -117,6 +118,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CHAT_ITEM_TAIL: true, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, + DEFAULT_SUPPORTER_BANNER_SHOWN: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -148,6 +150,7 @@ let hintDefaults = [ DEFAULT_LA_NOTICE_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, + DEFAULT_SUPPORTER_BANNER_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -341,6 +344,21 @@ struct SettingsView: View { } } + Section { + NavigationLink { + BadgesSupportSimplexView() + .modifier(ThemedBackground()) + } label: { + ZStack(alignment: .leading) { + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("Supporter perks").padding(.leading, indent) + } + } + } + Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) { NavigationLink { NetworkAndServers() @@ -375,7 +393,7 @@ struct SettingsView: View { NavigationLink { VersionView() .navigationBarTitle("App version") - .modifier(ThemedBackground()) + .modifier(ThemedBackground(grouped: true)) } label: { Text(verbatim: "v\(appVersion ?? "?")") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3b0c6ed57d..211b53e4f7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -145,6 +145,13 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; + 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */; }; + 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */; }; + 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */; }; + 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */; }; + 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */; }; + 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */; }; + 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FB3020A5AD0056E083 /* BadgesPayView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; @@ -524,6 +531,13 @@ 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; + 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; + 641377FB3020A5AD0056E083 /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; + 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; + 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; + 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; + 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; + 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; @@ -784,6 +798,7 @@ 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, + E5BADE0100000000BADE0000 /* Badges */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); path = Views; @@ -1238,6 +1253,20 @@ path = "SimpleX SE"; sourceTree = ""; }; + E5BADE0100000000BADE0000 /* Badges */ = { + isa = PBXGroup; + children = ( + 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */, + 641377FB3020A5AD0056E083 /* BadgesPayView.swift */, + 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */, + 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */, + 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */, + 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */, + 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */, + ); + path = Badges; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1693,6 +1722,13 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, + 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */, + 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */, + 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */, + 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */, + 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */, + 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */, + 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ddc9454b86..fae43f9069 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -302,11 +302,22 @@ public struct BadgeInfo: Codable, Hashable { public var badgeType: BadgeType public var badgeExpiry: Date? public var badgeExtra: String + + public init(badgeType: BadgeType, badgeExpiry: Date? = nil, badgeExtra: String = "") { + self.badgeType = badgeType + self.badgeExpiry = badgeExpiry + self.badgeExtra = badgeExtra + } } public struct LocalBadge: Codable, Hashable { public var badge: BadgeInfo public var status: BadgeStatus + + public init(badge: BadgeInfo, status: BadgeStatus) { + self.badge = badge + self.status = status + } } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 037c02b1c2..436d7e8059 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -192,6 +192,7 @@ class AppPreferences { val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) + val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) @@ -273,6 +274,7 @@ class AppPreferences { hintPref(laNoticeShown, false), hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), + hintPref(supporterBannerShown, false), hintPref(liveMessageAlertShown, false), hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), @@ -464,6 +466,7 @@ class AppPreferences { private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" + private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt new file mode 100644 index 0000000000..6380e5cf79 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -0,0 +1,41 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeInfo +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.NameWithBadge +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.res.MR + +@Composable +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { + val user = chatModel.currentUser.value + val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) + val previewBadge = LocalBadge( + badge = BadgeInfo(badgeType = level.badgeType), + status = BadgeStatus.Active + ) + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProfileImage(size = 128.dp, image = user?.image) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + trailing() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt new file mode 100644 index 0000000000..7c16bee763 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -0,0 +1,34 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.res.MR + +// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. +@Composable +fun BadgesHowItWorksView() { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(MR.strings.badges_how_it_works_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text(stringResource(MR.strings.badges_how_it_works_p1), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p2), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p3), style = MaterialTheme.typography.body1) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt new file mode 100644 index 0000000000..742b03e2bd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -0,0 +1,154 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgePeriod { + OneMonth, + Subscribe; + + val icon: dev.icerock.moko.resources.ImageResource + get() = when (this) { + OneMonth -> MR.images.ic_calendar + Subscribe -> MR.images.ic_refresh + } + + val label: StringResource + get() = when (this) { + OneMonth -> MR.strings.badges_period_one_month + Subscribe -> MR.strings.badges_period_subscribe + } +} + +@Composable +fun BadgesPayView(level: BadgeLevel) { + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(level.title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + + Text( + stringResource(level.tagline), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height + // when 2-line labels at large fonts would otherwise size them differently. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a + // TextButton so the footer has no hover/click affordance. + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, selectedPeriod) + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { + val isSelected = period == selectedPeriod + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(period) } + .padding(vertical = 30.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painterResource(period.icon), + contentDescription = null, + tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier.size(32.dp) + ) + Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once, + labelArg = level.priceAmount, + onboarding = null, + onclick = { + // TODO [badges] wire to purchase API when it lands. + } + ) +} + +private fun billingFooter(period: BadgePeriod): StringResource = when (period) { + BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month +} + +// TODO [badges] source the actual date from the purchase state machine when wired. +private fun stubBillingDate(): String { + val date = java.time.LocalDate.of(2026, 7, 22) + val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) + return date.format(formatter) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt new file mode 100644 index 0000000000..8a1069c536 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -0,0 +1,29 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.res.MR + +// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined. +@Composable +fun BadgesRedeemCodeView() { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + stringResource(MR.strings.badges_redeem_code_button), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt new file mode 100644 index 0000000000..aa8100519b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -0,0 +1,141 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.onboarding.HowItWorks +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +@Composable +fun BadgesSupportSimplexView() { + // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_support_simplex_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(MR.strings.badges_support_simplex_body), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + val primary = MaterialTheme.colors.primary + TextButton({ + ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } + + Spacer(Modifier.weight(1f)) + + PhoneSupporterHero(Modifier.fillMaxWidth(0.55f)) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ChooseLevelButton() + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_redeem_code_button), + onClick = { ModalManager.start.showModal { BadgesRedeemCodeView() } } + ) + } + } +} + +@Composable +private fun ChooseLevelButton() { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_choose_your_level, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesYourLevelView() } + } + ) +} + + +@Composable +fun PhoneSupporterHero(modifier: Modifier = Modifier) { + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.fillMaxWidth() + ) + } else { + var size by remember { mutableStateOf(IntSize.Zero) } + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + val aspect = size.height.toFloat() / size.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + Box( + modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(brush) + .onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(96.dp) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt new file mode 100644 index 0000000000..03082526a0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -0,0 +1,157 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeType +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgeLevel { + Supporter, + Legend; + + val title: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter + Legend -> MR.strings.badges_level_legend + } + + val filesDescription: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_files + Legend -> MR.strings.badges_level_legend_files + } + + val priceAmount: String + get() = when (this) { + Supporter -> "$7" + Legend -> "$70" + } + + val tagline: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_tagline + Legend -> MR.strings.badges_level_legend_tagline + } + + val badgeType: BadgeType + get() = when (this) { + Supporter -> BadgeType.Supporter + Legend -> BadgeType.Legend + } +} + +@Composable +fun BadgesYourLevelView() { + var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_your_level_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { + Icon( + painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height + // when 2-line labels at large fonts would otherwise size them differently. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + LevelCard(BadgeLevel.Supporter, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under + // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ContinueButton(selectedLevel) + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_how_it_works_button), + icon = painterResource(MR.images.ic_info), + onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } } + ) + } + } +} + +@Composable +private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Modifier, onSelect: (BadgeLevel) -> Unit) { + val isSelected = level == selectedLevel + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(level) } + .padding(vertical = 20.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painterResource(badgeImage(level.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(60.dp) + ) + Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + } +} + +@Composable +private fun ContinueButton(selectedLevel: BadgeLevel) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_continue, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesPayView(selectedLevel) } + } + ) +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt new file mode 100644 index 0000000000..55a8cef525 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -0,0 +1,173 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.res.MR + +@Composable +fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { + val cardCornerRadius = 16.dp + // grows linearly with system font but never shrinks below the default so small-font users see the + // same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text + // wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts. + val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp) + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + val cardLeadingPadding = 16.dp + val cardTrailingPadding = 8.dp + val heroWidth = 110.dp + // shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom + val heroVisibleHeight = 108.dp + // hero right edge sits at the dismiss X's icon left edge (X: outer 4pt + inner-pad 8 + half of 16pt icon) + val heroTrailingPadding = 28.dp + val textToHeroGap = 6.dp + + val isDark = isInDarkTheme() + var cardSize by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) } + + // Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero + // overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts). + Layout(content = { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = cardHeight) + .clip(RoundedCornerShape(cardCornerRadius)) + .background(brush) + .clickable(onClick = onTap) + .onSizeChanged { cardSize = it } + .padding( + start = cardLeadingPadding, + end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap, + top = 12.dp, + bottom = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(MR.strings.badges_banner_title), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(MR.strings.badges_banner_subtitle), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area. + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + tint = if (isDark) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 4.dp, top = 4.dp) + .clip(CircleShape) + .clickable { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_banner_title), + text = generalGetString(MR.strings.badges_banner_dismiss_message), + onConfirm = onDismiss + ) + } + .padding(8.dp) + .size(16.dp) + ) + } + + HeroThumbnail( + heroWidth = heroWidth, + heroVisibleHeight = heroVisibleHeight, + cardHeight = cardHeight, + trailingPadding = heroTrailingPadding + ) + }) { measurables, constraints -> + val cardPlaceable = measurables[0].measure(constraints) + val heroPlaceable = measurables[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + layout(cardPlaceable.width, cardPlaceable.height) { + cardPlaceable.place(0, 0) + heroPlaceable.place(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height) + } + } +} + +@Composable +private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, trailingPadding: Dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + // draws at natural aspect, top-aligned in a shorter slot; ContentScale.Crop cuts the overflow at card bottom + Image( + painterResource(if (isInDarkTheme()) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.padding(end = trailingPadding).size(width = heroWidth, height = heroVisibleHeight) + ) + } else { + val badgeSize = 48.dp + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(end = trailingPadding + 12.dp, top = (cardHeight - badgeSize) / 2, bottom = (cardHeight - badgeSize) / 2) + .size(badgeSize) + ) + } +} + +// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than +// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. +private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { + val stops = if (isDark) darkStops else lightStops + if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) + val w = size.width.toFloat() + val h = size.height.toFloat() + val startScale = if (isDark) 3.0f else 2.5f + val endScale = if (isDark) 2.1f else 1.7f + val gp = gradientPoints(h / w, 1.0f) + val sx = 0.5f + (gp.startX - 0.5f) * startScale + val sy = 0.5f + (gp.startY - 0.5f) * startScale + val ex = 0.5f + (gp.endX - 0.5f) * endScale + val ey = 0.5f + (gp.endY - 0.5f) * endScale + return Brush.linearGradient( + colorStops = stops, + start = Offset(sx * w, sy * h), + end = Offset(ex * w, ey * h) + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 68fa25d553..19f50de743 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -41,6 +41,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* @@ -912,6 +913,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val supporterBannerShown = remember { appPrefs.supporterBannerShown.state } val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -1000,6 +1002,16 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ToggleChatListCard() } } + if (!supporterBannerShown.value && chatModel.chats.value.size > 3) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + onTap = { ModalManager.start.showModal { BadgesSupportSimplexView() } }, + onDismiss = { appPrefs.supporterBannerShown.set(true) } + ) + } + } + } itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value @@ -1025,13 +1037,15 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } else { NavigationBarBackground(oneHandUI.value, true) } - if (!oneHandUICardShown.value) { - LaunchedEffect(chats.size) { - if (chats.size >= 3) { - appPrefs.oneHandUICardShown.set(true) - } - } - } + // TEMP-DISABLED-FOR-BADGES-QA: auto-hide of ToggleChatListCard at 3+ chats blocks visual QA of + // the SupportSimpleXBanner alongside it. Restore before merging. + // if (!oneHandUICardShown.value) { + // LaunchedEffect(chats.size) { + // if (chats.size >= 3) { + // appPrefs.oneHandUICardShown.set(true) + // } + // } + // } LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index d2ee1db09c..567e575bbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -245,7 +245,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) } } -private fun badgeImage(t: BadgeType): ImageResource = when (t) { +fun badgeImage(t: BadgeType): ImageResource = when (t) { is BadgeType.Legend -> MR.images.badge_legend is BadgeType.Investor -> MR.images.badge_investor else -> MR.images.badge_supporter // Supporter + Unknown diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 703d295523..9d82395740 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -7,6 +7,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -22,12 +23,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null, titleColor: Color = Color.Unspecified) { Column(Modifier.fillMaxSize().padding(horizontal = if (appPlatform.isDesktop) DEFAULT_PADDING * 2 else DEFAULT_PADDING)) { Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) val paraPadding = PaddingValues(bottom = if (appPlatform.isDesktop) 10.dp else 12.dp) Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING).verticalScroll(rememberScrollState())) { - Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) + Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, color = titleColor, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) ReadableText(MR.strings.why_built_p1, padding = paraPadding) ReadableText(MR.strings.why_built_p2, padding = paraPadding) ReadableText(MR.strings.why_built_p3, padding = paraPadding) @@ -45,6 +46,11 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() }) TextButtonBelowOnboardingButton("", null) } + } else { + // No button below — add breathing room at the bottom on both platforms. + // Android also gets nav-bar inset so content doesn't run under the gesture bar. + Spacer(Modifier.height(DEFAULT_PADDING)) + if (appPlatform.isAndroid) Spacer(Modifier.navigationBarsPadding()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 74dadcd671..d97d709953 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -190,12 +190,14 @@ expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference fun OnboardingActionButton( modifier: Modifier = Modifier, labelId: StringResource, + labelArg: String? = null, onboarding: OnboardingStage?, enabled: Boolean = true, icon: Painter? = null, iconColor: Color = Color.White, onclick: (() -> Unit)? ) { + val label = if (labelArg != null) stringResource(labelId).format(labelArg) else stringResource(labelId) Button( onClick = { onclick?.invoke() @@ -211,9 +213,9 @@ fun OnboardingActionButton( colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary) ) { if (icon != null) { - Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) + Icon(icon, label, Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) } - Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text(label, style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ea95bc2045..d3e1376af0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -940,8 +941,54 @@ private val versionDescriptions: List = listOf( ), ) ), + // TODO [badges] finalise copy + Read more link before v7.1 ships. + VersionDescription( + version = "v7.1", + post = null, + features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v7_1_supporter_badge_title, + view = { SupporterBadgeWhatsNew() } + ) + ) + ), ) +@Composable +private fun SupporterBadgeWhatsNew() { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(MR.strings.v7_1_supporter_badge_title), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold + ) + Text( + stringResource(MR.strings.v7_1_supporter_badge_body), + style = MaterialTheme.typography.body1, + maxLines = 10 + ) + } + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(110.dp) + ) + } else { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + } +} + private val lastVersion = versionDescriptions.last().version fun setLastVersionDefault(m: ChatModel) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 96f36da6d7..e398a1b2fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -26,6 +26,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgesSupportSimplexView import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView @@ -101,6 +102,17 @@ fun SettingsLayout( } SectionDividerSpaced() + SectionView { + // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render + // a gray top bar / back button that badges views don't want (they have their own inline titles). + SectionItemView(click = { ModalManager.start.showModal { BadgesSupportSimplexView() } }) { + Image(painterResource(MR.images.badge_supporter), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) + TextIconSpaced() + Text(stringResource(MR.strings.supporter_perks)) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.advanced_settings)) { SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) if (appPlatform == AppPlatform.ANDROID) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ead51b31ea..8683af1705 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3201,4 +3201,36 @@ This badge could not be verified and may not be genuine. Badge cannot be verified The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + Supporter + Legend + Send 2GB files + Send 5GB files + %1$s/month + Pay %1$s/month + Pay %1$s + Optional profile badge\nand 2GB files + Optional profile badge\nand 5GB files + 1 month + Subscribe + Renews on %1$s. Cancel anytime. + Ends on %1$s. + Your level + Support SimpleX + SimpleX doesn\'t sell ads or data. It\'s funded by its users and by investors who share the mission. You can support the project and show a badge on your profile. + Why SimpleX is built. + Choose your level + Redeem badge code + Continue + How private badges work + How private badges work + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + My nickname + Support SimpleX + Get badge + files up to 5GB + You can support SimpleX later in Settings. + Supporter perks + Supporter badge ❤️ + Help keep the network running — send files up to 2 GB. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg @@ -0,0 +1,4 @@ + + + + From 9c845222e4a528d229ef0b8b90f36f1d97805296 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:12:09 +0000 Subject: [PATCH 04/16] ui: badges app store integration (#7374) --- apps/ios/BadgeTestProducts.storekit | 230 ++++++++++++++++++ apps/ios/Shared/Views/Badges/BadgeStore.swift | 183 ++++++++++++++ .../Shared/Views/Badges/BadgesPayView.swift | 149 +++++++++++- .../Badges/BadgesSupportSimplexView.swift | 2 + .../Views/Badges/BadgesYourLevelView.swift | 18 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 78 +++--- .../foss/java/chat/simplex/app/PlayStore.kt | 18 ++ .../google/java/chat/simplex/app/PlayStore.kt | 190 +++++++++++++++ .../main/java/chat/simplex/app/SimplexApp.kt | 7 + .../chat/simplex/common/platform/Platform.kt | 12 + .../simplex/common/views/badges/BadgeStore.kt | 187 ++++++++++++++ .../common/views/badges/BadgesPayView.kt | 143 +++++++++-- .../views/badges/BadgesSupportSimplexView.kt | 3 + .../views/badges/BadgesYourLevelView.kt | 10 +- .../commonMain/resources/MR/base/strings.xml | 12 +- 15 files changed, 1157 insertions(+), 85 deletions(-) create mode 100644 apps/ios/BadgeTestProducts.storekit create mode 100644 apps/ios/Shared/Views/Badges/BadgeStore.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt diff --git a/apps/ios/BadgeTestProducts.storekit b/apps/ios/BadgeTestProducts.storekit new file mode 100644 index 0000000000..f32c2c8dc3 --- /dev/null +++ b/apps/ios/BadgeTestProducts.storekit @@ -0,0 +1,230 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "B4D6E1A0", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "7.00", + "familyShareable" : false, + "internalID" : "3010000001", + "localizations" : [ + { + "description" : "Supporter badge for one month", + "displayName" : "SimpleX Supporter Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_SUPPORTER_01", + "referenceName" : "SimpleX Supporter Badge", + "type" : "Consumable" + }, + { + "displayPrice" : "70.00", + "familyShareable" : false, + "internalID" : "3010000002", + "localizations" : [ + { + "description" : "Legend badge for one month", + "displayName" : "SimpleX Legend Badge", + "locale" : "en_US" + } + ], + "productID" : "BADGE_LEGEND_01", + "referenceName" : "SimpleX Legend Badge", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "", + "_developerTeamID" : "", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 0, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + { + "id" : "3020000001", + "localizations" : [ + + ], + "name" : "SimpleX Badge Subscription", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "7.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000011", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews monthly", + "displayName" : "SimpleX Supporter Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Supporter Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "42.00", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "3020000012", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Supporter badge, renews yearly", + "displayName" : "SimpleX Supporter Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_SUPPORTER_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Supporter Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "70.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000013", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews monthly", + "displayName" : "SimpleX Legend Badge (Month)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_MONTH_01", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "SimpleX Legend Badge (Month)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "420.00", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "3020000014", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Legend badge, renews yearly", + "displayName" : "SimpleX Legend Badge (Year)", + "locale" : "en_US" + } + ], + "productID" : "SUBSCR_BADGE_LEGEND_YEAR_01", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "SimpleX Legend Badge (Year)", + "subscriptionGroupID" : "3020000001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift new file mode 100644 index 0000000000..8d092bbc19 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -0,0 +1,183 @@ +// +// BadgeStore.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 14.08.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import Foundation +import Combine +import StoreKit + +// TODO [badges] product ids will come from app config and prices from the badge service catalog; +// hardcoded here so the App Store integration can be tested before the purchase API lands. +func badgeProductId(_ level: BadgeLevel, _ period: BadgePeriod) -> String { + switch (level, period) { + case (.supporter, .oneMonth): "BADGE_SUPPORTER_01" + case (.supporter, .monthly): "SUBSCR_BADGE_SUPPORTER_MONTH_01" + case (.supporter, .annual): "SUBSCR_BADGE_SUPPORTER_YEAR_01" + case (.legend, .oneMonth): "BADGE_LEGEND_01" + case (.legend, .monthly): "SUBSCR_BADGE_LEGEND_MONTH_01" + case (.legend, .annual): "SUBSCR_BADGE_LEGEND_YEAR_01" + } +} + +let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in + BadgePeriod.allCases.map { badgeProductId(level, $0) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction, +// which is how the service learns which invoice a store transaction settles. +func newBadgeInvoiceId() -> UUID { UUID() } + +enum BadgePrice { + case loading + case price(String) + case unavailable + + var canPurchase: Bool { + switch self { + case .price: true + case .loading, .unavailable: false + } + } +} + +struct BadgeStoreReceipt { + // the signed token the badge service verifies - never transaction.jsonRepresentation + let jws: String + let productId: String + let transactionId: UInt64 + let invoiceId: UUID? + let environment: String? + let signatureVerified: Bool +} + +enum BadgePurchaseOutcome { + case purchased(BadgeStoreReceipt) + case pending + case cancelled +} + +enum BadgeStoreError: Error { + case productUnavailable(productId: String) + case unknownPurchaseResult +} + +final class BadgeStore: ObservableObject { + static let shared = BadgeStore() + + private enum LoadState { case notLoaded, loading, loaded, failed } + + @Published private var state: LoadState = .notLoaded + private var products: [String: Product] = [:] + + private init() {} + + func price(_ level: BadgeLevel, _ period: BadgePeriod) -> BadgePrice { + switch state { + case .notLoaded, .loading: return .loading + case .loaded, .failed: + if let p = products[badgeProductId(level, period)] { return .price(compactPrice(p)) } + return .unavailable + } + } + + // percentage the annual subscription saves against 12 monthly payments + func annualSavings(_ level: BadgeLevel) -> Int? { + guard let monthly = products[badgeProductId(level, .monthly)], + let annual = products[badgeProductId(level, .annual)] + else { return nil } + let year = monthly.price * 12 + guard year > 0, annual.price < year else { return nil } + let saved = (year - annual.price) / year * 100 + let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded()) + return percent > 0 ? percent : nil + } + + func load() async { + guard await startLoading() else { return } + do { + let loaded = try await Product.products(for: badgeProductIds) + let byId = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { p, _ in p }) + let missing = badgeProductIds.filter { byId[$0] == nil } + if !missing.isEmpty { + // the store drops ids it cannot resolve without saying why, so the storefront and + // the bundle the request was made for are logged with them + let storefront = await Storefront.current + logger.warning("BadgeStore.load: no product returned for \(missing.joined(separator: ", ")) - bundle \(Bundle.main.bundleIdentifier ?? "?"), storefront \(storefront?.countryCode ?? "none"), canMakePayments \(AppStore.canMakePayments)") + } + await MainActor.run { + products = byId + state = .loaded + } + } catch let error { + logger.error("BadgeStore.load: \(String(describing: error))") + await MainActor.run { state = .failed } + } + } + + func purchase(_ level: BadgeLevel, _ period: BadgePeriod, invoiceId: UUID) async throws -> BadgePurchaseOutcome { + let productId = badgeProductId(level, period) + guard let product = await MainActor.run(body: { products[productId] }) else { + throw BadgeStoreError.productUnavailable(productId: productId) + } + switch try await product.purchase(options: [.appAccountToken(invoiceId)]) { + case let .success(verification): + let transaction: Transaction + let signatureVerified: Bool + switch verification { + case let .verified(t): + transaction = t + signatureVerified = true + case let .unverified(t, _): + transaction = t + signatureVerified = false + } + // nothing is delivered in this build, so the transaction is finished right away; once the + // service issues credentials it must only be finished after the credential is stored + await transaction.finish() + return .purchased(storeReceipt(verification.jwsRepresentation, transaction, signatureVerified)) + case .pending: return .pending + case .userCancelled: return .cancelled + @unknown default: throw BadgeStoreError.unknownPurchaseResult + } + } + + @MainActor + private func startLoading() -> Bool { + switch state { + case .notLoaded, .failed: + state = .loading + return true + case .loading, .loaded: + return false + } + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency style; +// Product.displayPrice remains the exact form for views that need the cents +private func compactPrice(_ product: Product) -> String { + var whole = Decimal() + var price = product.price + NSDecimalRound(&whole, &price, 0, .plain) + return whole == product.price + ? product.price.formatted(product.priceFormatStyle.precision(.fractionLength(0))) + : product.displayPrice +} + +private func storeReceipt(_ jws: String, _ t: Transaction, _ signatureVerified: Bool) -> BadgeStoreReceipt { + var environment: String? = nil + if #available(iOS 16.0, *) { environment = t.environment.rawValue } + return BadgeStoreReceipt( + jws: jws, + productId: t.productID, + transactionId: t.id, + invoiceId: t.appAccountToken, + environment: environment, + signatureVerified: signatureVerified + ) +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift index b32c14485f..707bdb3059 100644 --- a/apps/ios/Shared/Views/Badges/BadgesPayView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -12,29 +12,62 @@ import SimpleXChat // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum BadgePeriod: String, CaseIterable, Identifiable { case oneMonth - case subscribe + case monthly + case annual var id: String { rawValue } var icon: String { switch self { case .oneMonth: "calendar" - case .subscribe: "arrow.clockwise" + case .monthly: "arrow.clockwise" + case .annual: "arrow.clockwise" } } var label: LocalizedStringKey { switch self { case .oneMonth: "1 month" - case .subscribe: "Subscribe" + case .monthly: "Monthly" + case .annual: "Annual" + } + } + + func priceText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text(verbatim: "…") + case .unavailable: return Text(verbatim: "—") + case let .price(p): + switch self { + case .oneMonth: return Text(verbatim: p) + case .monthly: return Text("\(p)/month") + case .annual: return Text("\(p)/year") + } + } + } + + func payText(_ price: BadgePrice) -> Text { + switch price { + case .loading: return Text("Loading…") + case .unavailable: return Text("Not available") + case let .price(p): + switch self { + case .oneMonth: return Text("Pay \(p)") + case .monthly: return Text("Pay \(p)/month") + case .annual: return Text("Pay \(p)/year") + } } } } struct BadgesPayView: View { @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = .subscribe + @State private var selectedPeriod: BadgePeriod = .monthly + @State private var purchasing = false + // presented from this view, not AlertManager: its host is behind the sheet these views open in + @State private var alert: SomeAlert? var body: some View { GeometryReader { g in @@ -59,10 +92,14 @@ struct BadgesPayView: View { Spacer(minLength: 20) + // fixedSize + maxHeight on the cards so all three match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts HStack(alignment: .top, spacing: 12) { periodCard(.oneMonth) - periodCard(.subscribe) + periodCard(.monthly) + periodCard(.annual) } + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) @@ -86,6 +123,8 @@ struct BadgesPayView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } + .alert(item: $alert) { $0.alert } } private func periodCard(_ period: BadgePeriod) -> some View { @@ -99,13 +138,21 @@ struct BadgesPayView: View { .scaledToFit() .frame(width: 32, height: 32) .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) - .padding(.top, 30) Text(period.label) .font(.title3) .fontWeight(.bold) - .padding(.bottom, 30) + period.priceText(store.price(level, period)) + .font(.body) + if let percent = savingsPercent(period) { + Text("Save \(percent)%") + .font(.footnote) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + } } - .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.vertical, 25) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( @@ -116,13 +163,89 @@ struct BadgesPayView: View { .buttonStyle(.plain) } + private func savingsPercent(_ period: BadgePeriod) -> Int? { + period == .annual ? store.annualSavings(level) : nil + } + private func payButton() -> some View { - Button { - // TODO [badges] wire to purchase API when it lands. + let price = store.price(level, selectedPeriod) + let disabled = !price.canPurchase || purchasing + return Button { + purchase() } label: { - Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)") + selectedPeriod.payText(price) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func purchase() { + let period = selectedPeriod + let invoiceId = newBadgeInvoiceId() + purchasing = true + Task { + do { + let outcome = try await store.purchase(level, period, invoiceId: invoiceId) + await MainActor.run { + purchasing = false + switch outcome { + case let .purchased(receipt): showPurchasedAlert(receipt, invoiceId) + case .pending: + alert = SomeAlert( + alert: mkAlert( + title: "Purchase pending", + message: "The purchase is awaiting approval. This build does not deliver purchases approved later." + ), + id: "badgePurchasePending" + ) + case .cancelled: break + } + } + } catch let error { + logger.error("BadgesPayView.purchase: \(String(describing: error))") + await MainActor.run { + purchasing = false + alert = SomeAlert( + alert: Alert( + title: Text("Purchase error"), + message: Text(verbatim: String(describing: error)) + ), + id: "badgePurchaseError" + ) + } + } + } + } + + // TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. + private func showPurchasedAlert(_ receipt: BadgeStoreReceipt, _ invoiceId: UUID) { + let returnedInvoice: String + if let returned = receipt.invoiceId { + returnedInvoice = returned == invoiceId ? "yes" : "mismatch: \(returned.uuidString)" + } else { + returnedInvoice = "none" + } + var lines = [ + "Product: \(receipt.productId)", + "Invoice: \(invoiceId.uuidString)", + "Invoice returned by Apple: \(returnedInvoice)", + "Transaction: \(receipt.transactionId)" + ] + if let environment = receipt.environment { lines.append("Environment: \(environment)") } + lines.append("Signature: \(receipt.signatureVerified ? "verified" : "unverified")") + lines.append("Token: \(receipt.jws.count) bytes") + let summary = lines.joined(separator: "\n") + // logged as well as shown: the alert races StoreKit's own sheets, the log always lands + logger.debug("badge purchase succeeded\n\(summary)") + alert = SomeAlert( + alert: Alert( + title: Text("Purchase successful"), + message: Text(verbatim: summary), + primaryButton: .default(Text(verbatim: "Copy token")) { UIPasteboard.general.string = receipt.jws }, + secondaryButton: .cancel(Text("Ok")) + ), + id: "badgePurchased" + ) } private var billingFooter: LocalizedStringKey { @@ -131,7 +254,7 @@ struct BadgesPayView: View { let stubDate = Calendar.current.date(from: comps) ?? Date() let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) switch selectedPeriod { - case .subscribe: return "Renews on \(date). Cancel anytime." + case .monthly, .annual: return "Renews on \(date). Cancel anytime." case .oneMonth: return "Ends on \(date)." } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 6ae3176e85..2538d874a1 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -62,6 +62,8 @@ struct BadgesSupportSimplexView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + // preloaded here so the level screen shows store prices without a placeholder pass + .task { await BadgeStore.shared.load() } } private func whyBuiltButton() -> some View { diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift index 3cfd576ea9..eb2396c655 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -30,13 +30,6 @@ enum BadgeLevel: String, CaseIterable, Identifiable { } } - var priceAmount: String { - switch self { - case .supporter: "$7" - case .legend: "$70" - } - } - var tagline: LocalizedStringKey { switch self { case .supporter: "Optional profile badge\nand 2GB files" @@ -54,6 +47,7 @@ enum BadgeLevel: String, CaseIterable, Identifiable { struct BadgesYourLevelView: View { @EnvironmentObject var theme: AppTheme + @ObservedObject private var store = BadgeStore.shared @State private var selectedLevel: BadgeLevel = .supporter @State private var continueActive = false @State private var howItWorksActive = false @@ -78,10 +72,13 @@ struct BadgesYourLevelView: View { Spacer(minLength: 20) + // fixedSize + maxHeight on the cards so both match the taller one when a + // store price wraps in one of them HStack(alignment: .top, spacing: 12) { levelCard(.supporter) levelCard(.legend) } + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 20) @@ -101,6 +98,7 @@ struct BadgesYourLevelView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) + .task { await store.load() } } private func levelCard(_ level: BadgeLevel) -> some View { @@ -120,11 +118,13 @@ struct BadgesYourLevelView: View { Text(level.filesDescription) .font(.subheadline) .foregroundColor(theme.colors.secondary) - Text("\(level.priceAmount)/month") + BadgePeriod.monthly.priceText(store.price(level, .monthly)) .font(.body) .padding(.bottom, 20) } - .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 211b53e4f7..a9a1fa180e 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -145,13 +145,6 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; - 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */; }; - 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */; }; - 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */; }; - 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */; }; - 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */; }; - 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */; }; - 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FB3020A5AD0056E083 /* BadgesPayView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; @@ -186,6 +179,14 @@ 64A77A022DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF1302F423300072BDE /* BadgesPayView.swift */; }; + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */; }; + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */; }; + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */; }; + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF4302F423300072BDE /* BadgeStore.swift */; }; + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */; }; + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */; }; + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */; }; 64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; }; 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; @@ -531,15 +532,9 @@ 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; - 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; - 641377FB3020A5AD0056E083 /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; - 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; - 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; - 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; - 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; - 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = BadgeTestProducts.storekit; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; 643B3B4D2CCFD6400083A2CF /* OperatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorView.swift; sourceTree = ""; }; 6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = ""; }; @@ -573,6 +568,14 @@ 64A77A012DC4AD6100FDEF2F /* ContextPendingMemberActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPendingMemberActionsView.swift; sourceTree = ""; }; 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; + 64C03BF1302F423300072BDE /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; + 64C03BF4302F423300072BDE /* BadgeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeStore.swift; sourceTree = ""; }; + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; @@ -786,6 +789,7 @@ 5C2E260D27A30E2400F70299 /* Views */ = { isa = PBXGroup; children = ( + 64C03BF8302F423300072BDE /* Badges */, B76E6C2F2C5C41C300EC11AA /* Contacts */, 5CB0BA8C282711BC00B3292C /* Onboarding */, 3C714775281C080100CB4D4B /* Call */, @@ -798,7 +802,6 @@ 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, - E5BADE0100000000BADE0000 /* Badges */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); path = Views; @@ -907,6 +910,7 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 642E412B3033084000F04EE1 /* BadgeTestProducts.storekit */, E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, @@ -1201,6 +1205,21 @@ path = Group; sourceTree = ""; }; + 64C03BF8302F423300072BDE /* Badges */ = { + isa = PBXGroup; + children = ( + 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, + 64C03BF1302F423300072BDE /* BadgesPayView.swift */, + 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, + 64C03BF3302F423300072BDE /* BadgesSupportSimplexView.swift */, + 64C03BF4302F423300072BDE /* BadgeStore.swift */, + 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, + 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, + 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, + ); + path = Badges; + sourceTree = ""; + }; 8C74C3E92C1B909200039E77 /* Theme */ = { isa = PBXGroup; children = ( @@ -1253,20 +1272,6 @@ path = "SimpleX SE"; sourceTree = ""; }; - E5BADE0100000000BADE0000 /* Badges */ = { - isa = PBXGroup; - children = ( - 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */, - 641377FB3020A5AD0056E083 /* BadgesPayView.swift */, - 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */, - 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */, - 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */, - 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */, - 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */, - ); - path = Badges; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1631,6 +1636,14 @@ 640417CD2B29B8C200CCB412 /* NewChatMenuButton.swift in Sources */, 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */, 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */, + 64C03BF9302F423300072BDE /* BadgesPayView.swift in Sources */, + 64C03BFA302F423300072BDE /* BadgesRedeemCodeView.swift in Sources */, + 64C03BFB302F423300072BDE /* BadgesHowItWorksView.swift in Sources */, + 64C03BFC302F423300072BDE /* BadgeUserPreview.swift in Sources */, + 64C03BFD302F423300072BDE /* BadgeStore.swift in Sources */, + 64C03BFE302F423300072BDE /* SupportSimpleXBanner.swift in Sources */, + 64C03BFF302F423300072BDE /* BadgesYourLevelView.swift in Sources */, + 64C03C00302F423300072BDE /* BadgesSupportSimplexView.swift in Sources */, 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */, 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */, 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */, @@ -1722,13 +1735,6 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, - 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */, - 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */, - 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */, - 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */, - 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */, - 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */, - 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index 181fe42389..bf4a3fb71d 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -1,4 +1,22 @@ package chat.simplex.app +import chat.simplex.common.platform.Log +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId + // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} + +// No store in this flavor: no product is offered, so the purchase screen shows nothing to buy +// TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these +@Suppress("UNUSED_PARAMETER") +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "loadBadgeProducts: no store in the foss build, use the google variant to buy from Play") + return emptyList() +} + +@Suppress("UNUSED_PARAMETER") +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = + throw BadgeStoreError.StoreUnavailable diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index a0e7734ff0..14e8fc470c 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -1,8 +1,14 @@ package chat.simplex.app +import chat.simplex.common.platform.Log import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry +import chat.simplex.common.platform.mainActivity +import chat.simplex.common.views.badges.* import com.android.billingclient.api.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext // Requests the country of the Google Play account into [androidPlayStoreCountry]. // It stays null when Play is unavailable or the user is not signed in. @@ -29,3 +35,187 @@ fun loadPlayStoreCountry() { override fun onBillingServiceDisconnected() = client.endConnection() }) } + +// One long-lived client for badges: ProductDetails obtained from it are passed back to it when the +// purchase is launched, and the purchase result arrives on its listener rather than as a return value. +// volatile: the listener is called on the main thread, the purchase runs on a background dispatcher +@Volatile private var badgeBillingClient: BillingClient? = null +@Volatile private var badgeOffers: Map = emptyMap() +@Volatile private var badgePurchase: CompletableDeferred? = null + +// offerToken is null for one-time products, which have no base plan to choose +private class BadgeOffer( + val id: BadgeStoreProductId, + val product: BadgeProduct, + val details: ProductDetails, + val offerToken: String? +) + +suspend fun loadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + val client = connectedBadgeBillingClient() + val details = queryBadgeProducts(client, oneTimeIds, BillingClient.ProductType.INAPP) + + queryBadgeProducts(client, subscriptionIds, BillingClient.ProductType.SUBS) + val detailsByProductId = details.associateBy { it.productId } + val ids = oneTimeIds + subscriptionIds + val offers = ids.mapNotNull { id -> + val productDetails = detailsByProductId[id.productId] + if (productDetails == null) { + Log.w(TAG, "loadBadgeProducts: Play returned no product ${id.productId}") + null + } else { + productDetails.badgeOffer(id) + } + } + if (offers.size < ids.size) { + // a debug build's applicationIdSuffix is a common cause of Play not knowing the package + Log.w(TAG, "loadBadgeProducts: ${offers.size} of ${ids.size} resolved - package ${androidAppContext.packageName}, country ${androidPlayStoreCountry.value ?: "none"}") + } + badgeOffers = offers.associateBy { it.id } + return offers.map { it.product } +} + +suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome { + val activity = mainActivity.get() ?: throw BadgeStoreError.StoreUnavailable + val client = connectedBadgeBillingClient() + val offer = badgeOffers[id] ?: throw BadgeStoreError.ProductUnavailable(id.productId) + val details = offer.details + val productParams = BillingFlowParams.ProductDetailsParams.newBuilder().setProductDetails(details) + offer.offerToken?.let { productParams.setOfferToken(it) } + val params = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productParams.build())) + .setObfuscatedAccountId(invoiceId) + .build() + val purchase = CompletableDeferred() + badgePurchase = purchase + try { + val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) } + if (launched.responseCode != BillingClient.BillingResponseCode.OK) { + throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) + } + val outcome = purchase.await() + if (outcome is BadgePurchaseOutcome.Purchased) finishBadgePurchase(client, details, outcome.receipt) + return outcome + } finally { + badgePurchase = null + } +} + +private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> + val pending = badgePurchase ?: return@PurchasesUpdatedListener + when { + result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> + pending.complete(badgePurchaseOutcome(purchases)) + result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> + pending.complete(BadgePurchaseOutcome.Cancelled) + else -> + pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + } +} + +private fun badgePurchaseOutcome(purchases: List): BadgePurchaseOutcome { + val purchase = purchases.firstOrNull() ?: return BadgePurchaseOutcome.Cancelled + if (purchase.purchaseState == Purchase.PurchaseState.PENDING) return BadgePurchaseOutcome.Pending + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = purchase.purchaseToken, + productId = purchase.products.firstOrNull() ?: "", + orderId = purchase.orderId, + invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId + ) + ) +} + +private suspend fun connectedBadgeBillingClient(): BillingClient { + badgeBillingClient?.let { if (it.isReady) return it } + val client = BillingClient.newBuilder(androidAppContext) + .setListener(badgePurchasesUpdatedListener) + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + val connected = CompletableDeferred() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + connected.complete(result) + } + + override fun onBillingServiceDisconnected() { + badgeBillingClient = null + connected.complete( + BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build() + ) + } + }) + val result = connected.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) + } + badgeBillingClient = client + return client +} + +private suspend fun queryBadgeProducts(client: BillingClient, ids: List, productType: String): List { + // durations of one subscription share a product id, so the same product is queried once + val requested = ids.map { it.productId }.distinct() + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + requested.map { + QueryProductDetailsParams.Product.newBuilder().setProductId(it).setProductType(productType).build() + } + ) + .build() + val queried = CompletableDeferred>() + client.queryProductDetailsAsync(params) { result, productDetailsResult -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + queried.complete(productDetailsResult.productDetailsList) + } else { + Log.w(TAG, "queryBadgeProducts: $productType requested $requested, failed ${result.responseCode} ${result.debugMessage}") + queried.complete(emptyList()) + } + } + return queried.await() +} + +private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { + if (id.basePlanId == null) { + val purchase = oneTimePurchaseOfferDetails + if (purchase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no one-time purchase price, type is $productType") + return null + } + val product = BadgeProduct(id, purchase.formattedPrice, purchase.priceAmountMicros, purchase.priceCurrencyCode) + return BadgeOffer(id, product, this, offerToken = null) + } + val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId } + if (offer == null) { + Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}") + return null + } + val phase = offer.pricingPhases.pricingPhaseList.firstOrNull { + it.recurrenceMode == ProductDetails.RecurrenceMode.INFINITE_RECURRING + } + if (phase == null) { + Log.w(TAG, "badgeOffer: ${id.productId} base plan ${id.basePlanId} has no recurring price") + return null + } + val product = BadgeProduct(id, phase.formattedPrice, phase.priceAmountMicros, phase.priceCurrencyCode) + return BadgeOffer(id, product, this, offer.offerToken) +} + +// nothing is delivered in this build, so the purchase is finished right away; once the service issues +// credentials it must only be finished after the credential is stored. One-time products are consumed +// so they can be bought again, subscriptions are acknowledged - Play refunds an unacknowledged +// purchase after 3 days. +private suspend fun finishBadgePurchase(client: BillingClient, details: ProductDetails, receipt: BadgeStoreReceipt) { + val done = CompletableDeferred() + if (details.productType == BillingClient.ProductType.SUBS) { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(receipt.token).build() + client.acknowledgePurchase(params) { done.complete(it) } + } else { + val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() + client.consumeAsync(params) { result, _ -> done.complete(result) } + } + val result = done.await() + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + Log.e(TAG, "finishBadgePurchase: ${result.responseCode} ${result.debugMessage}") + } +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index ce47d2c5de..9826908e77 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -26,6 +26,9 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreProductId import chat.simplex.common.views.call.* import chat.simplex.common.views.database.deleteOldChatArchive import chat.simplex.common.views.helpers.* @@ -343,6 +346,10 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) + + override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index b46123c9cf..d857ab974c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -9,6 +9,10 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatId import chat.simplex.common.model.NotificationsMode import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.badges.BadgeProduct +import chat.simplex.common.views.badges.BadgePurchaseOutcome +import chat.simplex.common.views.badges.BadgeStoreError +import chat.simplex.common.views.badges.BadgeStoreProductId import kotlinx.coroutines.Job import java.io.Closeable @@ -31,6 +35,14 @@ interface PlatformInterface { fun androidIsXiaomiDevice(): Boolean = false // Requests the Google Play account country into [androidPlayStoreCountry] fun androidLoadPlayStoreCountry() {} + // Play Billing, only implemented in the google flavor + // TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any + // product until that path is implemented + suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { + Log.w(TAG, "androidLoadBadgeProducts: no store on this platform") + return emptyList() + } + suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = throw BadgeStoreError.StoreUnavailable val androidApiLevel: Int? get() = null // The build distributed via Google Play, which has to follow its policies val androidIsPlayStoreBuild: Boolean get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt new file mode 100644 index 0000000000..f8a37c39a7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt @@ -0,0 +1,187 @@ +package chat.simplex.common.views.badges + +import androidx.compose.runtime.mutableStateOf +import chat.simplex.common.platform.* +import java.text.NumberFormat +import java.util.Currency +import java.util.UUID + +// a subscription is one store product containing a base plan per duration, so a purchasable badge +// is identified by both; one-time products have no base plan +data class BadgeStoreProductId(val productId: String, val basePlanId: String? = null) + +// TODO [badges] ids will come from app config and prices from the badge service catalog; +// hardcoded here so the Play Store integration can be tested before the purchase API lands. +fun badgeStoreProductId(level: BadgeLevel, period: BadgePeriod): BadgeStoreProductId = when (level) { + BadgeLevel.Supporter -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_supporter_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-month-02") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_supporter_01", "subscr-badge-supporter-year-01") + } + BadgeLevel.Legend -> when (period) { + BadgePeriod.OneMonth -> BadgeStoreProductId("badge_legend_01") + BadgePeriod.Monthly -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-month-01") + BadgePeriod.Annual -> BadgeStoreProductId("subscr_badge_legend_01", "subscr-badge-legend-year-01") + } +} + +val badgeStoreProductIds: List = BadgeLevel.entries.flatMap { level -> + BadgePeriod.entries.map { badgeStoreProductId(level, it) } +} + +// TODO [badges] replaced by APIGetBadgeInvoice, which creates the invoice row and returns its id. +// Sent to Play as obfuscatedAccountId and echoed back on the purchase, which is how the service +// learns which invoice a store transaction settles. +fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() + +// what the platform store knows about one product; ProductDetails cannot cross into commonMain +data class BadgeProduct( + val id: BadgeStoreProductId, + val displayPrice: String, + val priceMicros: Long, + val currencyCode: String +) + +sealed class BadgePrice { + object Loading: BadgePrice() + class Price(val price: String): BadgePrice() + object Unavailable: BadgePrice() + + val canPurchase: Boolean + get() = when (this) { + is Price -> true + is Loading, is Unavailable -> false + } +} + +data class BadgeStoreReceipt( + // the token the badge service verifies with the Publisher API + val token: String, + val productId: String, + val orderId: String?, + val invoiceId: String?, + // only set for test products - a real Play purchase has no environment to report + val environment: String? = null +) + +// TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens +// and walk the purchase flow without Play Console products; the purchase is simulated and its +// receipt says so. +const val useBadgeTestProducts = false + +private fun testProduct(level: BadgeLevel, period: BadgePeriod, priceMicros: Long) = + BadgeProduct(badgeStoreProductId(level, period), "\$${priceMicros / 1_000_000}.00", priceMicros, "USD") + +private val testBadgeProducts: List = listOf( + testProduct(BadgeLevel.Supporter, BadgePeriod.OneMonth, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Monthly, 7_000_000), + testProduct(BadgeLevel.Supporter, BadgePeriod.Annual, 42_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.OneMonth, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Monthly, 70_000_000), + testProduct(BadgeLevel.Legend, BadgePeriod.Annual, 420_000_000) +) + +sealed class BadgePurchaseOutcome { + class Purchased(val receipt: BadgeStoreReceipt): BadgePurchaseOutcome() + object Pending: BadgePurchaseOutcome() + object Cancelled: BadgePurchaseOutcome() +} + +sealed class BadgeStoreError: Exception() { + class ProductUnavailable(val productId: String): BadgeStoreError() + class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() + object StoreUnavailable: BadgeStoreError() + + override val message: String + get() = when (this) { + is ProductUnavailable -> "productUnavailable(productId: $productId)" + is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" + is StoreUnavailable -> "storeUnavailable" + } +} + +object BadgeStore { + private enum class LoadState { NotLoaded, Loading, Loaded, Failed } + + private val state = mutableStateOf(LoadState.NotLoaded) + // snapshot state so a composable reading only the products still recomposes when they arrive + private val products = mutableStateOf>(emptyMap()) + + fun price(level: BadgeLevel, period: BadgePeriod): BadgePrice = when (state.value) { + LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading + LoadState.Loaded, LoadState.Failed -> { + val p = products.value[badgeStoreProductId(level, period)] + if (p != null) BadgePrice.Price(compactPrice(p)) else BadgePrice.Unavailable + } + } + + fun annualSavings(level: BadgeLevel): Int? { + val monthly = products.value[badgeStoreProductId(level, BadgePeriod.Monthly)] ?: return null + val annual = products.value[badgeStoreProductId(level, BadgePeriod.Annual)] ?: return null + val year = monthly.priceMicros * 12 + if (year <= 0 || annual.priceMicros >= year) return null + val percent = Math.round((year - annual.priceMicros).toDouble() / year * 100).toInt() + return if (percent > 0) percent else null + } + + suspend fun load() { + if (!startLoading()) return + try { + // TODO [badges] desktop and the foss build will price from the badge service catalog and pay + // via Stripe/crypto instead of a store; only the google build reaches the platform store + val loaded = if (useBadgeTestProducts) testBadgeProducts else platform.androidLoadBadgeProducts( + oneTimeIds = badgeStoreProductIds.filter { it.basePlanId == null }, + subscriptionIds = badgeStoreProductIds.filter { it.basePlanId != null } + ) + val byId = loaded.associateBy { it.id } + val missing = badgeStoreProductIds.filter { !byId.containsKey(it) } + if (missing.isNotEmpty()) { + Log.w(TAG, "BadgeStore.load: no product returned for ${missing.joinToString(", ")}") + } + products.value = byId + state.value = LoadState.Loaded + } catch (e: Exception) { + Log.e(TAG, "BadgeStore.load: ${e.stackTraceToString()}") + state.value = LoadState.Failed + } + } + + suspend fun purchase(level: BadgeLevel, period: BadgePeriod, invoiceId: String): BadgePurchaseOutcome { + val id = badgeStoreProductId(level, period) + if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) + if (useBadgeTestProducts) { + return BadgePurchaseOutcome.Purchased( + BadgeStoreReceipt( + token = "test-${UUID.randomUUID()}", + productId = id.productId, + orderId = null, + invoiceId = invoiceId, + environment = "test products" + ) + ) + } + return platform.androidPurchaseBadge(id, invoiceId) + } + + private fun startLoading(): Boolean = when (state.value) { + LoadState.NotLoaded, LoadState.Failed -> { + state.value = LoadState.Loading + true + } + LoadState.Loading, LoadState.Loaded -> false + } +} + +// drops the fraction from whole amounts ("$7", not "$7.00") in the product's own currency; +// BadgeProduct.displayPrice remains the exact form for views that need the cents +private fun compactPrice(product: BadgeProduct): String { + if (product.priceMicros % 1_000_000L != 0L) return product.displayPrice + return try { + val format = NumberFormat.getCurrencyInstance() + format.currency = Currency.getInstance(product.currencyCode) + format.maximumFractionDigits = 0 + format.format(product.priceMicros / 1_000_000L) + } catch (e: Exception) { + product.displayPrice + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt index 742b03e2bd..d7a10f38f7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -10,6 +10,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -25,24 +28,52 @@ import chat.simplex.res.MR // TODO [badges]: replace with types produced by the badge purchase API when it lands. enum class BadgePeriod { OneMonth, - Subscribe; + Monthly, + Annual; val icon: dev.icerock.moko.resources.ImageResource get() = when (this) { OneMonth -> MR.images.ic_calendar - Subscribe -> MR.images.ic_refresh + Monthly -> MR.images.ic_refresh + Annual -> MR.images.ic_refresh } val label: StringResource get() = when (this) { OneMonth -> MR.strings.badges_period_one_month - Subscribe -> MR.strings.badges_period_subscribe + Monthly -> MR.strings.badges_period_monthly + Annual -> MR.strings.badges_period_annual } + + @Composable + fun priceText(price: BadgePrice): String = when (price) { + is BadgePrice.Loading -> "…" + is BadgePrice.Unavailable -> "—" + is BadgePrice.Price -> when (this) { + OneMonth -> price.price + Monthly -> stringResource(MR.strings.badges_price_monthly).format(price.price) + Annual -> stringResource(MR.strings.badges_price_annual).format(price.price) + } + } + + fun payLabel(price: BadgePrice): Pair = when (price) { + is BadgePrice.Loading -> MR.strings.badges_price_loading to null + is BadgePrice.Unavailable -> MR.strings.badges_price_unavailable to null + is BadgePrice.Price -> when (this) { + OneMonth -> MR.strings.badges_pay_once to price.price + Monthly -> MR.strings.badges_pay_monthly to price.price + Annual -> MR.strings.badges_pay_annual to price.price + } + } } @Composable fun BadgesPayView(level: BadgeLevel) { - var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) } + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Monthly) } + val purchasing = remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { BadgeStore.load() } ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), @@ -69,24 +100,25 @@ fun BadgesPayView(level: BadgeLevel) { modifier = Modifier.fillMaxWidth().padding(top = 4.dp) ) - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) - // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height - // when 2-line labels at large fonts would otherwise size them differently. + // IntrinsicSize.Max + fillMaxHeight on children so all three cards match the tallest one - + // only Annual carries a savings line, and prices wrap at large fonts. Row( Modifier.fillMaxWidth().height(IntrinsicSize.Max), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } - PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Monthly, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, BadgePeriod.Annual, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } } - Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a // TextButton so the footer has no hover/click affordance. Column(horizontalAlignment = Alignment.CenterHorizontally) { - PayButton(level, selectedPeriod) + PayButton(level, selectedPeriod, purchasing, clipboard) Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { Text( stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), @@ -101,7 +133,7 @@ fun BadgesPayView(level: BadgeLevel) { } @Composable -private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { +private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. @@ -114,7 +146,7 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie .background(cardBackground, shape) .border(2.dp, borderColor, shape) .clickable { onSelect(period) } - .padding(vertical = 30.dp, horizontal = 12.dp), + .padding(vertical = 20.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -125,24 +157,97 @@ private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifie modifier = Modifier.size(32.dp) ) Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(period.priceText(BadgeStore.price(level, period)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + val percent = savingsPercent(level, period) + if (percent != null) { + Text( + stringResource(MR.strings.badges_savings).format("${percent}%"), + style = MaterialTheme.typography.body2, + color = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } } } +private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = + if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null + +// TODO [badges] on desktop and the foss build there is no store, so every price is Unavailable and +// this button stays disabled - it will offer Stripe/crypto payment instead @Composable -private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) { +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val price = BadgeStore.price(level, selectedPeriod) + val (labelId, labelArg) = selectedPeriod.payLabel(price) OnboardingActionButton( modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), - labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once, - labelArg = level.priceAmount, + labelId = labelId, + labelArg = labelArg, onboarding = null, - onclick = { - // TODO [badges] wire to purchase API when it lands. + enabled = price.canPurchase && !purchasing.value, + onclick = { purchase(level, selectedPeriod, purchasing, clipboard) } + ) +} + +private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: MutableState, clipboard: ClipboardManager) { + val invoiceId = newBadgeInvoiceId() + purchasing.value = true + // not withBGApi: the purchase waits for the user in the Play sheet and would block chat API calls + withLongRunningApi { + try { + val outcome = BadgeStore.purchase(level, period, invoiceId) + purchasing.value = false + when (outcome) { + is BadgePurchaseOutcome.Purchased -> showPurchasedAlert(outcome.receipt, invoiceId, clipboard) + is BadgePurchaseOutcome.Pending -> AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_pending), + text = generalGetString(MR.strings.badges_purchase_pending_desc) + ) + is BadgePurchaseOutcome.Cancelled -> {} + } + } catch (e: Exception) { + Log.e(TAG, "BadgesPayView.purchase: ${e.stackTraceToString()}") + purchasing.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = e.toString() + ) } + } +} + +// TODO [badges] store integration diagnostics - replaced by the issued badge once the service lands. +private fun showPurchasedAlert(receipt: BadgeStoreReceipt, invoiceId: String, clipboard: ClipboardManager) { + val returnedInvoice = when (receipt.invoiceId) { + null -> "none" + invoiceId -> "yes" + else -> "mismatch: ${receipt.invoiceId}" + } + val lines = mutableListOf( + "Product: ${receipt.productId}", + "Invoice: $invoiceId", + "Invoice returned by Google: $returnedInvoice", + "Order: ${receipt.orderId ?: "none"}" + ) + if (receipt.environment != null) lines.add("Environment: ${receipt.environment}") + lines.add("Token: ${receipt.token.length} bytes") + val summary = lines.joinToString("\n") + Log.d(TAG, "badge purchase succeeded\n$summary") + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.badges_purchase_successful), + text = summary, + confirmText = "Copy token", + onConfirm = { + clipboard.setText(AnnotatedString(receipt.token)) + showToast(generalGetString(MR.strings.copied)) + }, + dismissText = generalGetString(MR.strings.ok), + parseHtml = false ) } private fun billingFooter(period: BadgePeriod): StringResource = when (period) { - BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.Monthly, BadgePeriod.Annual -> MR.strings.badges_billing_footer_subscribe BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index aa8100519b..099954a4f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -33,6 +33,9 @@ import chat.simplex.res.MR @Composable fun BadgesSupportSimplexView() { // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + // preloaded here so the level screen shows store prices without a placeholder pass + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt index 03082526a0..c77e4dd61e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -40,12 +40,6 @@ enum class BadgeLevel { Legend -> MR.strings.badges_level_legend_files } - val priceAmount: String - get() = when (this) { - Supporter -> "$7" - Legend -> "$70" - } - val tagline: StringResource get() = when (this) { Supporter -> MR.strings.badges_level_supporter_tagline @@ -63,6 +57,8 @@ enum class BadgeLevel { fun BadgesYourLevelView() { var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + LaunchedEffect(Unit) { BadgeStore.load() } + ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -139,7 +135,7 @@ private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Mo ) Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) - Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + Text(BadgePeriod.Monthly.priceText(BadgeStore.price(level, BadgePeriod.Monthly)), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 8683af1705..046d490863 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3206,12 +3206,18 @@ Send 2GB files Send 5GB files %1$s/month + %1$s/year Pay %1$s/month + Pay %1$s/year Pay %1$s + Loading… + Not available + Save %1$s Optional profile badge\nand 2GB files Optional profile badge\nand 5GB files 1 month - Subscribe + Monthly + Annual Renews on %1$s. Cancel anytime. Ends on %1$s. Your level @@ -3230,6 +3236,10 @@ Support SimpleX Get badge + files up to 5GB You can support SimpleX later in Settings. + Purchase successful + Purchase pending + The purchase is awaiting approval. This build does not deliver purchases approved later. + Purchase error Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. From 352550089ff533655c93638e65851c0eb8f213c3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 17 Aug 2026 19:25:18 +0100 Subject: [PATCH 05/16] core: client and service schema for badge purchases (#7358) * core: client and service schema for badge purchases * simplify api, receipt is payment type * split invoice * update --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../BadgeService/Store/Postgres/Migrations.hs | 26 +- .../BadgeService/Store/SQLite/Migrations.hs | 24 +- docs/protocol/badges-rpc.md | 10 +- docs/protocol/badges-rpc.schema.json | 59 ++-- .../2026-07-31-badges-core-implementation.md | 14 +- simplex-chat.cabal | 6 +- src/Simplex/Chat/Badges/Service.hs | 58 +--- .../Chat/Badges/{Store.hs => Types.hs} | 15 +- src/Simplex/Chat/PaymentService.hs | 31 ++ src/Simplex/Chat/PaymentService/Types.hs | 74 +++++ .../Migrations/M20260731_user_badges.hs | 227 ++++++++++++++ .../Migrations/M20260731_user_badges.hs | 281 +++++++++++------- 12 files changed, 586 insertions(+), 239 deletions(-) rename src/Simplex/Chat/Badges/{Store.hs => Types.hs} (94%) create mode 100644 src/Simplex/Chat/PaymentService.hs create mode 100644 src/Simplex/Chat/PaymentService/Types.hs create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260731_user_badges.hs diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index a4bc3396bf..1ff157f70c 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -1,11 +1,12 @@ {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) where import Data.List (sortOn) import Data.Text (Text) -import qualified Data.Text as T +import Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges (badgeSchema, badgeSchemaDown, withPrefix) import Simplex.Messaging.Agent.Store.Shared (Migration (..)) import Text.RawString.QQ (r) @@ -19,19 +20,18 @@ schemaMigrations = [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) ] +-- the client tables are in the same database, so the service tables are the same names with this prefix +servicePrefix :: Text +servicePrefix = "sx_badge_service_" + m20260806_badge_service_schema :: Text m20260806_badge_service_schema = - T.pack - [r| -CREATE TABLE sx_badge_service_test( - test_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - created_at TIMESTAMPTZ NOT NULL DEFAULT (now()) -); - |] + badgeSchema servicePrefix + <> withPrefix + servicePrefix + [r| +ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; +|] down_m20260806_badge_service_schema :: Text -down_m20260806_badge_service_schema = - T.pack - [r| -DROP TABLE sx_badge_service_test; - |] +down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index 8404f135db..eb3d5a0e64 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -1,11 +1,14 @@ {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} module BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) where import Data.List (sortOn) +import Data.Text (Text) import Database.SQLite.Simple (Query (..)) import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges (badgeSchema, badgeSchemaDown, withPrefix) import Simplex.Messaging.Agent.Store.Shared (Migration (..)) badgeServiceSchemaMigrations :: [Migration] @@ -18,17 +21,18 @@ schemaMigrations = [ ("20260806_badge_service_schema", m20260806_badge_service_schema, Just down_m20260806_badge_service_schema) ] +-- the client tables are in the same database, so the service tables are the same names with this prefix +servicePrefix :: Text +servicePrefix = "sx_badge_service_" + m20260806_badge_service_schema :: Query m20260806_badge_service_schema = - [sql| -CREATE TABLE sx_badge_service_test( - test_id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT(datetime('now')) -); - |] + badgeSchema servicePrefix + <> withPrefix + servicePrefix + [sql| +ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; +|] down_m20260806_badge_service_schema :: Query -down_m20260806_badge_service_schema = - [sql| -DROP TABLE sx_badge_service_test; - |] +down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 635b2d8e7c..7ce3b78b34 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -12,7 +12,7 @@ A request is an envelope: `version` — the client's protocol version; `purchase Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`, and a key it holds no record of with `unknown_purchase_key`. -A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `code`; `transferBadge` creates the receiving one (post-MVP). +A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, `code`, or `receipt`. ## Idempotency @@ -22,19 +22,19 @@ A timeout hides the outcome, so the client repeats the identical signed request - `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. -- `transferBadge` — a receipt used by the same key returns the same result; used by another key, `receipt_used`. +- `purchaseBadge` with a `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands `purchaseBadge`, `upgradeBadgeSubscription`, and `issueBadge` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3); its absence requests a lifetime credential; `badgeExtra` is reserved and must be empty. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. -- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr): `badgeType`, `months`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `expiresAt`, and `paymentTo` — `url` for card; `address` and `cryptoAmount` for crypto. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`), records the credit, and issues the first credential, in one round trip. `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. + - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. - `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). -- `transferBadge` (post-MVP) → `badgeCredential` — the receipt moves the remaining balance and the provider binding to the signing key; the transferred period's re-issue debits a month, and the response holds a fresh receipt, retiring the presented one. Lifetime badges are not transferable by the command — support handles them. ## Upgrades diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 2caa3408e2..00ecc69874 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -67,6 +67,29 @@ } } }, + "invoice": { + "properties": { + "invoiceId": {"type": "string"}, + "price": {"ref": "currencyAmount"}, + "amount": { + "ref": "currencyAmount", + "metadata": {"comment": "price - discount - credit"} + }, + "currency": {"type": "string"}, + "expiresAt": {"type": "timestamp"}, + "paymentTo": {"ref": "paymentTo"} + }, + "optionalProperties": { + "discount": { + "ref": "currencyAmount", + "metadata": {"comment": "discount amount from the price"} + }, + "credit": { + "ref": "currencyAmount", + "metadata": {"comment": "credit for upgrade"} + } + } + }, "payment": { "discriminator": "type", "mapping": { @@ -81,6 +104,14 @@ }, "code": { "properties": {"code": {"type": "string"}} + }, + "receipt": { + "properties": { + "receipt": { + "type": "string", + "metadata": {"comment": "transfer of unissued months"} + } + } } } }, @@ -297,12 +328,6 @@ }, "pauseBadge": { "optionalProperties": {} - }, - "transferBadge": { - "properties": { - "badgeRequest": {"ref": "badgeRequest"}, - "receipt": {"type": "string"} - } } } }, @@ -322,27 +347,9 @@ }, "badgeInvoice": { "properties": { - "invoiceId": {"type": "string"}, + "invoice": {"ref": "invoice"}, "badgeType": {"type": "string"}, - "months": {"type": "uint8"}, - "price": {"ref": "currencyAmount"}, - "amount": { - "ref": "currencyAmount", - "metadata": {"comment": "price - discount - credit"} - }, - "currency": {"type": "string"}, - "expiresAt": {"type": "timestamp"}, - "paymentTo": {"ref": "paymentTo"} - }, - "optionalProperties": { - "discount": { - "ref": "currencyAmount", - "metadata": {"comment": "discount amount from monthly price"} - }, - "credit": { - "ref": "currencyAmount", - "metadata": {"comment": "credit for upgrade"} - } + "months": {"type": "uint8"} } }, "badgeCredential": { diff --git a/plans/2026-07-31-badges-core-implementation.md b/plans/2026-07-31-badges-core-implementation.md index 4928522b97..6a143cd022 100644 --- a/plans/2026-07-31-badges-core-implementation.md +++ b/plans/2026-07-31-badges-core-implementation.md @@ -3,7 +3,7 @@ **Date:** 2026-07-31 **Product plan:** `plans/2026-07-30-supporter-badges-v3-ux.md` (referenced below as UX §n) **Transport:** service RPC (`plans/2026-07-22-service-rpc-chat.md`, implemented, branch `rpc`) -**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge | transferBadge` are defined in the protocol, post-MVP; no `use_from`, `paused_at`. +**Scope:** MVP launch set (UX §7): commands `getBadgeCatalog | getBadgeInvoice | purchaseBadge | issueBadge` implemented; `upgradeBadgeSubscription | pauseBadge` and the `receipt` payment are defined in the protocol, post-MVP; no `use_from`, `paused_at`. ## 1. Client schema @@ -79,7 +79,7 @@ The protocol entry (`StatementEntry`, §4): `entryId`, `changeMonths`, `balanceM ## 3. Client types -Domain types — `src/Simplex/Chat/Badges/Store.hs`. Records: +Domain types — `src/Simplex/Chat/Badges/Types.hs`. Records: - `BadgePurchase` - `BadgePayment` @@ -126,6 +126,7 @@ Protocol types — `src/Simplex/Chat/Badges/Service.hs`, one constructor/field p - `BadgeServiceRequest` - `BadgeServiceCommand` - `ServicePaymentMethod` +- `ServiceInvoice` - `CardProvider` - `CryptoCurrency` - `CurrencyAmount` @@ -159,12 +160,11 @@ A request is an envelope: `version`; `purchaseKey`? (optional for `getBadgeCatal | request `type` | request fields (beyond `purchaseKey`, `version`) | response `type` | response fields | |---|---|---|---| | `getBadgeCatalog` (signature optional) | — | `badgeCatalog` | `catalog`
`badgeStatement`? (for signed requests) | -| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoiceId`
`badgeType`
`months`
`price`
`discount`?
`credit`?
`amount` (= price − discount − credit)
`currency`
`expiresAt`
`paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount` | -| `purchaseBadge` | `badgeRequest` — `masterKey`, `badgeInfo`
`payment` — `apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`
`receipt`? (not provided for lifetime badges)
`statement` | +| `getBadgeInvoice` | `priceId`
`offerId`? (absent for 1 month at the badge price)
`badgeInfo {badgeType, badgeExpiry?, badgeExtra}`
`paymentVia` — `card`: `provider`; `crypto`: `currency`
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeInvoice` | `invoice` — `invoiceId`, `price`, `discount`?, `credit`?, `amount` (= price − discount − credit), `currency`, `expiresAt`, `paymentTo` — `card`: `provider`, `url`; `crypto`: `currency`, `address`, `cryptoAmount`
`badgeType`
`months` | +| `purchaseBadge` | `badgeRequest` — `masterKey`, `badgeInfo`
`payment` — `apple`: `jws`; `google`: `token`; `invoice`: `invoiceId`; `code`: `code`; `receipt`: `receipt` (transfer, post-MVP)
`upgrade`? — `fromPurchaseKey`, `receipt`, `receiptSignature`, `balance` | `badgeCredential` | `credential`
`receipt`? (not provided for lifetime badges)
`statement` | | `upgradeBadgeSubscription` | `badgeRequest`
`payment` — `apple`: `jws`; `google`: `token`
`balance` | `badgeCredential` | `credential`?
`statement` | | `issueBadge` | `badgeRequest`
`balance` | `badgeCredential` | `credential`? (absent when the balance is exhausted)
`statement` | | `pauseBadge` (post-MVP) | — | `badgeCredential` | `credential`?
`statement` | -| `transferBadge` (post-MVP) | `badgeRequest`
`receipt` | `badgeCredential` | `credential`
`receipt`?
`statement` | | any, on failure | — | `error` | `code` (incl. `payment_pending`, `code_invalid` / `code_used` / `code_expired`)
`message`?
`retryAfter`? | `statement` — record: `entries` — ledger entries; `previousEntryId`? — matches the client's asserted entryId, absent for the full ledger. @@ -219,7 +219,7 @@ Purchase is two commands because the store purchase runs in the app between them - `APIGetBadgeInvoice` starts every purchase: core loads or creates the live purchase row for the badge type's slot (per-user lock + `idx_badge_purchases_live`) — a `failed` row of the same slot is reused — creates the payment row, and points the badge row's `payment_id` at it (UX §3: the current payment). For `card` and `crypto` core sends `getBadgeInvoice` with the pinned `priceId` and the `offerId` of the chosen duration, and responds with the invoice — the Stripe link or the crypto screen data (UX 2.1). For `apple` and `google` core writes the rows, generates the invoice id itself, and sends nothing, since prices come from the store and SKUs from app config (§4); the app states the store because core is the same on both platforms. The invoice fields are stored on the payment row (§1), so pending-payment screens re-render after a restart; after invoice expiry a new `APIGetBadgeInvoice` creates a new invoice and payment row (UX 2.1). - The app passes the invoice id to the store as the account token — Apple `Product.PurchaseOption.appAccountToken` (a UUID, echoed in the signed transaction), Google `BillingFlowParams.setObfuscatedAccountId` — so the store transaction states which payment it settles, and the service reads it from the verified store payload at `purchaseBadge`. - `APIPurchaseBadge` completes a store purchase — the only payment whose result is delivered to the app: the store hands the app the evidence, and only that evidence ties the store transaction to the purchase, because the store flow knows neither purchase keys nor the service. Core records it on the payment row; the worker sends `purchaseBadge` — the service verifies, records the credit, and issues in one round trip (§6). Card and crypto payments need no completion command and carry no evidence: the service records their settlement from the provider webhook (UX §7 notifications); the worker's next `issueBadge` returns the credential, or `payment_pending` until the webhook arrives (§4). -- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (`transferBadge`, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7). +- `APIPurchaseBadge` with a `code` sends the same `purchaseBadge` under the user lock, and differs only in the order of the writes: keys are generated first, and the badge and payment rows (`provider = code`, `price_id` and `offer_id` NULL) are written on success in one transaction, directly `issued`, because the badge type — and with it the slot — is stated in the response (UX 2.8). A live row of the granted slot is superseded (at most two badges per profile, UX 2.7); its unconsumed months stay on its purchase — purchases are unlinkable, so the service cannot move them; recovery per UX §3 (the `receipt` payment, post-MVP). On a timeout the error is surfaced to the user; a code consumed by a lost response is restored by support (codes tooling, delivery 7). - `APIGetBadgeState` loads the badge state into the app model at start (and on profile switch); events only update the model afterward, so without the initial read it would hold nothing at first render — the 2.2 banner is rendered from it. It reads stored state and sends nothing itself. The same call re-reads state when a badge screen is opened or regains focus, and signals the worker (§6); reconciliation results follow as `CEvtBadgeChanged`. Screen re-focus covers the returns that fire no core trigger: the store cancellation sheet close — UX §7 "the engine sends `status` on return"; the in-app sheet fires no foreground trigger — and return to a pending-payment screen after payment (UX 2.1), which on desktop produces no foreground event either. `ChatResponse`: @@ -392,7 +392,7 @@ Each UX plan point and its implementation home: | §3 catalog rules | `badge_prices` / `badge_offers`; reconciliation (delivery 4) | | §3 payments, charges | §1; the statement's payments and charges (§4) | | §3 ledger, issuances | replicas (§1); the `balance` assertion and the `opening` restatement (§4); service transitions (delivery 7); tests (delivery 8) | -| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); `transferBadge` post-MVP | +| §3 recovery | `payments.receipt_code`; signed `getBadgeCatalog` after restore (worker); capped store re-bind at `purchaseBadge` (delivery 7); the `receipt` payment post-MVP | | §4 wire protocol | `Badges/Service.hs`; `docs/protocol` | | §5 providers | delivery 7; the §5 command bullets | | §6 decisions 11–14 | 11 — `renews_at` / `cancelled` / charges kept; 12 — catalog seed (delivery 2); 13 — `sundayAfter`; 14 — `paidThrough` in `UserBadgeState` | diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 183b77d332..6a5bfc2d26 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -41,7 +41,7 @@ library Simplex.Chat.Badges Simplex.Chat.Badges.CLI Simplex.Chat.Badges.Service - Simplex.Chat.Badges.Store + Simplex.Chat.Badges.Types Simplex.Chat.Names Simplex.Chat.Call Simplex.Chat.Controller @@ -65,6 +65,8 @@ library Simplex.Chat.Operators.Presets Simplex.Chat.Options Simplex.Chat.Options.DB + Simplex.Chat.PaymentService + Simplex.Chat.PaymentService.Types Simplex.Chat.ProfileGenerator Simplex.Chat.Protocol Simplex.Chat.Remote @@ -155,6 +157,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges else exposed-modules: Simplex.Chat.Archive @@ -325,6 +328,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index eb5a556503..6f15d0fa16 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -10,14 +10,8 @@ module Simplex.Chat.Badges.Service BadgeServiceVersion, VersionBadgeService, pattern VersionBadgeService, - ServicePaymentMethod (..), - CardProvider (..), - CryptoCurrency (..), - CurrencyAmount (..), - ServicePayment (..), BadgeUpgrade (..), BadgeServiceResponse (..), - ServicePaymentDestination (..), BadgeServiceErrorCode (..), BadgeCatalog (..), BadgePrice (..), @@ -37,7 +31,8 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8, Word16, Word32) import Simplex.Chat.Badges -import Simplex.Chat.Badges.Store +import Simplex.Chat.Badges.Types +import Simplex.Chat.PaymentService import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version (VersionScope) @@ -82,32 +77,6 @@ data BadgeServiceCommand balance :: BadgeBalance } | BSCPauseBadge - | BSCTransferBadge - { badgeRequest :: BadgeRequest, - receipt :: Text - } - -data ServicePaymentMethod - = SPMCard {provider :: CardProvider} - | SPMCrypto {currency :: CryptoCurrency} - deriving (Eq, Show) - -data CardProvider = CPStripe - deriving (Eq, Show) - -data CryptoCurrency = CCBtc | CCXmr - deriving (Eq, Show) - --- USD etc. are in minor units, following Stripe etc. convention -newtype CurrencyAmount = CurrencyAmount Word32 - deriving (Eq, Show) - -data ServicePayment - = SPApple {jws :: Text} - | SPGoogle {token :: Text} - | SPInvoice {invoiceId :: InvoiceId} - | SPCode {code :: Text} - deriving (Show) data BadgeUpgrade = BadgeUpgrade { fromPurchaseKey :: C.PublicKeyEd25519, @@ -122,16 +91,9 @@ data BadgeServiceResponse badgeStatement :: Maybe BadgeStatement -- for signed getBadgeCatalog } | BSPBadgeInvoice - { invoiceId :: InvoiceId, + { invoice :: ServiceInvoice, badgeType :: BadgeType, - months :: Word8, - price :: CurrencyAmount, - discount :: Maybe CurrencyAmount, -- discount amount from monthly price - credit :: Maybe CurrencyAmount, -- credit for upgrade - amount :: CurrencyAmount, - currency :: Text, - expiresAt :: UTCTime, - paymentTo :: ServicePaymentDestination + months :: Word8 } | BSPBadgeCredential { credential :: Maybe BadgeCredential, -- Nothing when no balance to issueBadge or no current credential for pause @@ -144,18 +106,6 @@ data BadgeServiceResponse retryAfter :: Maybe Word32 } -data ServicePaymentDestination - = SPDCard - { provider :: CardProvider, - url :: Text - } - | SPDCrypto - { currency :: CryptoCurrency, - address :: Text, - cryptoAmount :: Text - } - deriving (Show) - data BadgeCatalog = BadgeCatalog { prices :: [BadgePrice], offers :: [BadgeOffer] diff --git a/src/Simplex/Chat/Badges/Store.hs b/src/Simplex/Chat/Badges/Types.hs similarity index 94% rename from src/Simplex/Chat/Badges/Store.hs rename to src/Simplex/Chat/Badges/Types.hs index 88357d1a6f..bf9042c944 100644 --- a/src/Simplex/Chat/Badges/Store.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -2,14 +2,12 @@ {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} -module Simplex.Chat.Badges.Store +module Simplex.Chat.Badges.Types ( BadgePriceId (..), BadgeOfferId (..), - InvoiceId (..), BadgePlan (..), BadgeItemStatus (..), OfferDiscount (..), - BadgeProvider (..), BadgePaymentStatus (..), BadgePurchaseStatus (..), LedgerEntryType (..), @@ -32,6 +30,7 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) +import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentProvider) import Simplex.Messaging.Agent.Protocol (UserId) import qualified Simplex.Messaging.Crypto as C @@ -43,10 +42,6 @@ newtype BadgePriceId = BadgePriceId Text newtype BadgeOfferId = BadgeOfferId Text deriving newtype (Eq, Show) --- to review -newtype InvoiceId = InvoiceId Text - deriving newtype (Eq, Show) - -- unconfirmed draft data BadgePlan = BPOneTime | BPMonthly | BPAnnual deriving (Eq, Show) @@ -61,10 +56,6 @@ data OfferDiscount | ODDiscount {discount :: Word8} -- percent deriving (Eq, Show) --- unconfirmed draft -data BadgeProvider = BPRApple | BPRGoogle | BPRStripe | BPRBtc | BPRXmr | BPRCode - deriving (Eq, Show) - -- unconfirmed draft data BadgePaymentStatus = BPSNew | BPSInvoiced | BPSPending | BPSSettled | BPSFailed | BPSExpired deriving (Eq, Show) @@ -133,7 +124,7 @@ data BadgePayment = BadgePayment months :: Maybe Int, amount :: Maybe Int64, currency :: Maybe Text, - provider :: BadgeProvider, + provider :: PaymentProvider, providerRef :: Maybe Text, invoiceUrl :: Maybe Text, invoiceAddress :: Maybe Text, diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs new file mode 100644 index 0000000000..a4484c469c --- /dev/null +++ b/src/Simplex/Chat/PaymentService.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Simplex.Chat.PaymentService + ( ServiceInvoice (..), + ServicePayment (..), + module Simplex.Chat.PaymentService.Types, + ) where + +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.PaymentService.Types + +data ServiceInvoice = ServiceInvoice + { invoiceId :: InvoiceId, + price :: CurrencyAmount, + discount :: Maybe CurrencyAmount, -- discount amount from the price + credit :: Maybe CurrencyAmount, -- credit for upgrade + amount :: CurrencyAmount, -- price - discount - credit + currency :: Text, + expiresAt :: UTCTime, + paymentTo :: ServicePaymentDestination + } + deriving (Show) + +data ServicePayment + = SPApple {jws :: Text} + | SPGoogle {token :: Text} + | SPInvoice {invoiceId :: InvoiceId} + | SPCode {code :: Text} + | SPReceipt {receipt :: Text} -- transfer of unissued months + deriving (Show) diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs new file mode 100644 index 0000000000..cb02ae72c2 --- /dev/null +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -0,0 +1,74 @@ +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + +module Simplex.Chat.PaymentService.Types + ( CurrencyAmount (..), + InvoiceId (..), + PaymentProvider (..), + CardProvider (..), + CryptoCurrency (..), + ServicePaymentMethod (..), + ServicePaymentDestination (..), + InvoiceStatus (..), + StoredInvoice (..), + ) where + +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Data.Word (Word32) + +-- USD etc. are in minor units, following Stripe etc. convention +newtype CurrencyAmount = CurrencyAmount Word32 + deriving (Eq, Show) + +-- confirmed +newtype InvoiceId = InvoiceId Text + deriving newtype (Eq, Show) + +-- confirmed +data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt + deriving (Eq, Show) + +data CardProvider = CPStripe + deriving (Eq, Show) + +data CryptoCurrency = CCBtc | CCXmr + deriving (Eq, Show) + +data ServicePaymentMethod + = SPMCard {provider :: CardProvider} + | SPMCrypto {currency :: CryptoCurrency} + deriving (Eq, Show) + +data ServicePaymentDestination + = SPDCard + { provider :: CardProvider, + url :: Text + } + | SPDCrypto + { currency :: CryptoCurrency, + address :: Text, + cryptoAmount :: Text + } + deriving (Show) + +-- confirmed +data InvoiceStatus = ISOpen | ISPaid | ISExpired + deriving (Eq, Show) + +-- confirmed +data StoredInvoice = StoredInvoice + { invoiceId :: InvoiceId, + price :: CurrencyAmount, + discountAmount :: CurrencyAmount, + creditAmount :: CurrencyAmount, + amount :: CurrencyAmount, -- price - discount - credit + currency :: Text, + paymentTo :: ServicePaymentDestination, + expiresAt :: UTCTime, + status :: InvoiceStatus, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Show) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260731_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260731_user_badges.hs new file mode 100644 index 0000000000..cecfe4afd1 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260731_user_badges.hs @@ -0,0 +1,227 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- the same tables in the client and in the badge service; '@' is replaced with the table name prefix +badgeSchema :: Text -> Text +badgeSchema pfx = withPrefix pfx badgeSchemaTables + +badgeSchemaDown :: Text -> Text +badgeSchemaDown pfx = withPrefix pfx badgeSchemaTablesDown + +withPrefix :: Text -> Text -> Text +withPrefix = T.replace "@" + +badgeSchemaTables :: Text +badgeSchemaTables = + [r| +CREATE TABLE @invoices( + invoice_id TEXT NOT NULL PRIMARY KEY, + provider TEXT NOT NULL, + price BIGINT NOT NULL, + discount_amount BIGINT, + credit_amount BIGINT, + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + payment_url TEXT, + payment_address TEXT, + payment_crypto_currency TEXT, + payment_crypto_amount TEXT, + expires_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE @payments( + payment_id TEXT NOT NULL PRIMARY KEY, + invoice_id TEXT REFERENCES @invoices, + provider TEXT NOT NULL, + provider_ref TEXT, + amount BIGINT, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + subscription_renews_at TIMESTAMPTZ, + grace_until TIMESTAMPTZ, + cancelled SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); + +CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); + +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + charged_at TIMESTAMPTZ NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +CREATE TABLE @badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price BIGINT NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE @badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES @badge_prices, + months SMALLINT NOT NULL, + free_months SMALLINT, + discount SMALLINT, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE @badge_purchases( + badge_purchase_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + purchase_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + initial_badge_type TEXT NOT NULL, + current_badge_type TEXT NOT NULL, + payment_id TEXT REFERENCES @payments, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(purchase_key), + UNIQUE(payment_id) +); + +CREATE TABLE @badge_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); + +CREATE TABLE @badge_subscription_changes( + change_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + from_badge_type TEXT NOT NULL, + to_badge_type TEXT NOT NULL, + from_provider_ref TEXT, + to_provider_ref TEXT, + effective TEXT NOT NULL, + status TEXT NOT NULL, + effective_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); + +CREATE TABLE @badge_ledger( + entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entry_uuid TEXT NOT NULL, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + change_months SMALLINT NOT NULL, + balance_months SMALLINT NOT NULL, + balance_start_ts TIMESTAMPTZ NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TIMESTAMPTZ, + service_created_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT, + payment_id TEXT REFERENCES @payments, + charge_id TEXT REFERENCES @subscription_charges, + from_purchase_id BIGINT REFERENCES @badge_purchases, + to_purchase_id BIGINT REFERENCES @badge_purchases +); + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); + +CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); + +CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id BIGINT REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + credential BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); +|] + +badgeSchemaTablesDown :: Text +badgeSchemaTablesDown = + [r| +DROP TABLE @badge_issuances; +DROP TABLE @badge_ledger; +DROP TABLE @badge_subscription_changes; +DROP TABLE @badge_invoices; +DROP TABLE @badge_purchases; +DROP TABLE @subscription_charges; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +m20260731_user_badges :: Text +m20260731_user_badges = + badgeSchema "" + <> [r| +ALTER TABLE badge_purchases ADD COLUMN user_id BIGINT REFERENCES users ON DELETE CASCADE; + +ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BYTEA; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TIMESTAMPTZ; + +ALTER TABLE payments ADD COLUMN evidence BYTEA; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; + +CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); + +ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; +|] + +down_m20260731_user_badges :: Text +down_m20260731_user_badges = + [r| +ALTER TABLE users DROP COLUMN shown_badge_id; +|] + <> badgeSchemaDown "" diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs index 08db8a49ca..e558cb4253 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260731_user_badges.hs @@ -1,69 +1,67 @@ +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} module Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges where -import Database.SQLite.Simple (Query) +import Data.Text (Text) +import qualified Data.Text as T +import Database.SQLite.Simple (Query (..)) import Database.SQLite.Simple.QQ (sql) -m20260731_user_badges :: Query -m20260731_user_badges = +-- the same tables in the client and in the badge service; '@' is replaced with the table name prefix +badgeSchema :: Text -> Query +badgeSchema pfx = withPrefix pfx badgeSchemaTables + +badgeSchemaDown :: Text -> Query +badgeSchemaDown pfx = withPrefix pfx badgeSchemaTablesDown + +withPrefix :: Text -> Query -> Query +withPrefix pfx = Query . T.replace "@" pfx . fromQuery + +badgeSchemaTables :: Query +badgeSchemaTables = [sql| -CREATE TABLE badge_prices( - price_id TEXT NOT NULL PRIMARY KEY, - badge_type TEXT NOT NULL, - month_price INTEGER NOT NULL, +CREATE TABLE @invoices( + invoice_id TEXT NOT NULL PRIMARY KEY, + provider TEXT NOT NULL, + price INTEGER NOT NULL, + discount_amount INTEGER, + credit_amount INTEGER, + amount INTEGER NOT NULL, currency TEXT NOT NULL, + payment_url TEXT, + payment_address TEXT, + payment_crypto_currency TEXT, + payment_crypto_amount TEXT, + expires_at TEXT NOT NULL, status TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); -CREATE TABLE badge_offers( - offer_id TEXT NOT NULL PRIMARY KEY, - price_id TEXT REFERENCES badge_prices, - months INTEGER NOT NULL, - discount_type TEXT NOT NULL, - free_months INTEGER, - discount INTEGER, - status TEXT NOT NULL, - created_at TEXT NOT NULL -); - -CREATE TABLE payments( - payment_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - purchase_key BLOB NOT NULL, - badge_type TEXT NOT NULL, - price_id TEXT REFERENCES badge_prices, - offer_id TEXT REFERENCES badge_offers, - invoice_uuid TEXT, - months INTEGER, - amount INTEGER, - currency TEXT, +CREATE TABLE @payments( + payment_id TEXT NOT NULL PRIMARY KEY, + invoice_id TEXT REFERENCES @invoices, provider TEXT NOT NULL, provider_ref TEXT, - invoice_url TEXT, - invoice_address TEXT, - invoice_crypto_amount TEXT, - invoice_expires_at TEXT, - evidence BLOB, - receipt_code TEXT, + amount INTEGER, + currency TEXT, status TEXT NOT NULL, exception TEXT, - renews_at TEXT, + subscription_renews_at TEXT, grace_until TEXT, cancelled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -CREATE INDEX idx_payments_purchase_key ON payments(purchase_key); +CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); -CREATE UNIQUE INDEX idx_payments_invoice_uuid ON payments(invoice_uuid); +CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); -CREATE TABLE charges( - charge_id INTEGER PRIMARY KEY, - payment_id INTEGER NOT NULL REFERENCES payments, - invoice_uuid TEXT NOT NULL, +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, provider_charge_ref TEXT NOT NULL, period_start TEXT NOT NULL, period_end TEXT NOT NULL, @@ -73,71 +71,70 @@ CREATE TABLE charges( UNIQUE(payment_id, provider_charge_ref) ); -CREATE TABLE badge_purchases( - badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - purchase_key BLOB NOT NULL, - purchase_priv_key BLOB NOT NULL, - master_key BLOB NOT NULL, +CREATE TABLE @badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, badge_type TEXT NOT NULL, - price_id TEXT REFERENCES badge_prices, - offer_id TEXT REFERENCES badge_offers, - payment_id INTEGER NOT NULL REFERENCES payments, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, status TEXT NOT NULL, - badge_key_idx INTEGER, - badge_signature BLOB, - badge_expiry TEXT, - alert_acked_kind TEXT, - alert_acked_episode TEXT, - alert_snooze_until TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE(purchase_key) -); - -CREATE UNIQUE INDEX idx_badge_purchases_live ON badge_purchases(user_id, (badge_type = 'investor')) - WHERE status IN ('acquiring', 'issued'); - -ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; -|] - <> badgeLedgerTable - <> [sql| -ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0; - -ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; - -CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); - -CREATE INDEX idx_badge_ledger_purchase ON badge_ledger(badge_purchase_id, entry_id); - -CREATE INDEX idx_badge_ledger_invoice ON badge_ledger(invoice_id); - -CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); - -CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); - -CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); - -CREATE TABLE badge_issuances( - issuance_id INTEGER PRIMARY KEY, - badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, - period_start TEXT, - period_end TEXT, - expiry TEXT, - entry_id INTEGER REFERENCES badge_ledger, created_at TEXT NOT NULL ); -CREATE INDEX idx_badge_issuances_purchase ON badge_issuances(badge_purchase_id, issuance_id); -|] +CREATE TABLE @badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES @badge_prices, + months INTEGER NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +); -badgeLedgerTable :: Query -badgeLedgerTable = - [sql| -CREATE TABLE badge_ledger( +CREATE TABLE @badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_key BLOB NOT NULL, + master_key BLOB NOT NULL, + initial_badge_type TEXT NOT NULL, + current_badge_type TEXT NOT NULL, + payment_id TEXT REFERENCES @payments, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(purchase_key), + UNIQUE(payment_id) +); + +CREATE TABLE @badge_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); + +CREATE TABLE @badge_subscription_changes( + change_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + from_badge_type TEXT NOT NULL, + to_badge_type TEXT NOT NULL, + from_provider_ref TEXT, + to_provider_ref TEXT, + effective TEXT NOT NULL, + status TEXT NOT NULL, + effective_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); + +CREATE TABLE @badge_ledger( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, entry_uuid TEXT NOT NULL, - badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, change_months INTEGER NOT NULL, balance_months INTEGER NOT NULL, balance_start_ts TEXT NOT NULL, @@ -148,22 +145,84 @@ CREATE TABLE badge_ledger( entry_type TEXT NOT NULL, entry_credit_type TEXT, entry_debit_type TEXT, - invoice_id INTEGER REFERENCES payments, - charge_id INTEGER REFERENCES charges, - from_purchase_id INTEGER REFERENCES badge_purchases, - to_purchase_id INTEGER REFERENCES badge_purchases + payment_id TEXT REFERENCES @payments, + charge_id TEXT REFERENCES @subscription_charges, + from_purchase_id INTEGER REFERENCES @badge_purchases, + to_purchase_id INTEGER REFERENCES @badge_purchases ); + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); + +CREATE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id); + +CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id INTEGER REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + expiry TEXT NOT NULL, + credential BLOB NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); +|] + +badgeSchemaTablesDown :: Query +badgeSchemaTablesDown = + [sql| +DROP TABLE @badge_issuances; +DROP TABLE @badge_ledger; +DROP TABLE @badge_subscription_changes; +DROP TABLE @badge_invoices; +DROP TABLE @badge_purchases; +DROP TABLE @subscription_charges; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +m20260731_user_badges :: Query +m20260731_user_badges = + badgeSchema "" + <> [sql| +ALTER TABLE badge_purchases ADD COLUMN user_id INTEGER REFERENCES users ON DELETE CASCADE; + +ALTER TABLE badge_purchases ADD COLUMN purchase_priv_key BLOB; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_kind TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; + +ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TEXT; + +ALTER TABLE payments ADD COLUMN evidence BLOB; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; + +CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); + +ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; |] down_m20260731_user_badges :: Query down_m20260731_user_badges = [sql| -DROP TABLE badge_issuances; -DROP TABLE badge_ledger; ALTER TABLE users DROP COLUMN shown_badge_id; -DROP TABLE badge_purchases; -DROP TABLE charges; -DROP TABLE payments; -DROP TABLE badge_offers; -DROP TABLE badge_prices; |] + <> badgeSchemaDown "" From 1c7bc1aed4f34cee85eeee1ca69e5781e29eb35b Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 27 Aug 2026 08:02:46 +0100 Subject: [PATCH 06/16] core: refactor badge types (#7387) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Badges/Types.hs | 40 +--------------- src/Simplex/Chat/PaymentService/Types.hs | 60 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index bf9042c944..70dd4dfa8c 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -8,14 +8,12 @@ module Simplex.Chat.Badges.Types BadgePlan (..), BadgeItemStatus (..), OfferDiscount (..), - BadgePaymentStatus (..), BadgePurchaseStatus (..), LedgerEntryType (..), LedgerCreditType (..), LedgerDebitType (..), BadgeAlertKind (..), BadgePurchase (..), - BadgePayment (..), BadgeLedgerEntry (..), BadgeCharge (..), BadgeIssuance (..), @@ -30,7 +28,7 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentProvider) +import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment) import Simplex.Messaging.Agent.Protocol (UserId) import qualified Simplex.Messaging.Crypto as C @@ -56,10 +54,6 @@ data OfferDiscount | ODDiscount {discount :: Word8} -- percent deriving (Eq, Show) --- unconfirmed draft -data BadgePaymentStatus = BPSNew | BPSInvoiced | BPSPending | BPSSettled | BPSFailed | BPSExpired - deriving (Eq, Show) - -- unconfirmed draft data BadgePurchaseStatus = PSAcquiring | PSIssued | PSSuperseded | PSFailed deriving (Eq, Show) @@ -112,36 +106,6 @@ data BadgePurchase = BadgePurchase updatedAt :: UTCTime } --- to review -data BadgePayment = BadgePayment - { paymentId :: Int64, - userId :: UserId, - purchaseKey :: C.PublicKeyEd25519, - badgeType :: BadgeType, - priceId :: Maybe BadgePriceId, - offerId :: Maybe BadgeOfferId, - invoiceUuid :: Maybe InvoiceId, - months :: Maybe Int, - amount :: Maybe Int64, - currency :: Maybe Text, - provider :: PaymentProvider, - providerRef :: Maybe Text, - invoiceUrl :: Maybe Text, - invoiceAddress :: Maybe Text, - invoiceCryptoAmount :: Maybe Text, - invoiceExpiresAt :: Maybe UTCTime, - evidence :: Maybe ByteString, - receiptCode :: Maybe Text, - status :: BadgePaymentStatus, - exception :: Maybe Text, - renewsAt :: Maybe UTCTime, - graceUntil :: Maybe UTCTime, - cancelled :: Bool, - createdAt :: UTCTime, - updatedAt :: UTCTime - } - deriving (Show) - -- confirmed data BadgeLedgerEntry = BadgeLedgerEntry { entryId :: Int64, @@ -197,7 +161,7 @@ data BadgeAlert = BadgeAlert data UserBadgeState = UserBadgeState { badges :: [BadgePurchase], shownBadgeId :: Maybe Int64, - payments :: [BadgePayment], + payments :: [StoredPayment], monthsLeft :: Int, paidThrough :: Maybe UTCTime, renewsAt :: Maybe UTCTime, diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs index cb02ae72c2..910a5b973f 100644 --- a/src/Simplex/Chat/PaymentService/Types.hs +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -5,6 +5,7 @@ module Simplex.Chat.PaymentService.Types ( CurrencyAmount (..), InvoiceId (..), + PaymentId (..), PaymentProvider (..), CardProvider (..), CryptoCurrency (..), @@ -12,8 +13,13 @@ module Simplex.Chat.PaymentService.Types ServicePaymentDestination (..), InvoiceStatus (..), StoredInvoice (..), + StoredPayment (..), + PaymentFunding (..), + PaymentTerm (..), + PaymentStatus (..), ) where +import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word32) @@ -26,6 +32,10 @@ newtype CurrencyAmount = CurrencyAmount Word32 newtype InvoiceId = InvoiceId Text deriving newtype (Eq, Show) +-- confirmed +newtype PaymentId = PaymentId Text + deriving newtype (Eq, Show) + -- confirmed data PaymentProvider = PPApple | PPGoogle | PPStripe | PPCrypto | PPCode | PPReceipt deriving (Eq, Show) @@ -72,3 +82,53 @@ data StoredInvoice = StoredInvoice updatedAt :: UTCTime } deriving (Show) + +-- to review +data StoredPayment = StoredPayment + { paymentId :: PaymentId, + funding :: PaymentFunding, + term :: PaymentTerm, + status :: PaymentStatus, + createdAt :: UTCTime, + updatedAt :: UTCTime + } + deriving (Show) + +-- to review +data PaymentFunding + = PFInvoice + { invoiceId :: InvoiceId, + providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + receiptCode :: Maybe Text -- client; the service holds its hash + } + | PFApple + { providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + evidence :: Maybe ByteString -- client only + } + | PFGoogle + { providerRef :: Text, + amount :: CurrencyAmount, + currency :: Text, + evidence :: Maybe ByteString -- client only + } + | PFCode + | PFReceipt + deriving (Show) + +-- to review +data PaymentTerm + = PTOneOff + | PTSubscription + { renewsAt :: UTCTime, + graceUntil :: Maybe UTCTime, + cancelled :: Bool + } + deriving (Show) + +-- to review +data PaymentStatus = PSPending | PSSettled | PSFailed {exception :: Text} + deriving (Show) From e80a174f1352bcd7ae29f47468153e7d9d2f39b0 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:31:24 +0000 Subject: [PATCH 07/16] core: badge codes schema, plan (#7427) --- .../common/views/chatlist/ChatListView.kt | 16 +- .../BadgeService/Store/Postgres/Migrations.hs | 38 +++- .../BadgeService/Store/SQLite/Migrations.hs | 38 +++- docs/protocol/badges-rpc.md | 10 +- docs/protocol/badges-rpc.schema.json | 9 +- plans/2026-08-27-badges-mvp-streams.md | 194 ++++++++++++++++++ src/Simplex/Chat/Badges/Service.hs | 4 + src/Simplex/Chat/Badges/Types.hs | 6 +- src/Simplex/Chat/PaymentService.hs | 1 - .../Migrations/M20261001_user_badges.hs | 34 +++ .../Migrations/M20261001_user_badges.hs | 34 +++ 11 files changed, 364 insertions(+), 20 deletions(-) create mode 100644 plans/2026-08-27-badges-mvp-streams.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 19f50de743..16a944d7bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1037,15 +1037,13 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } else { NavigationBarBackground(oneHandUI.value, true) } - // TEMP-DISABLED-FOR-BADGES-QA: auto-hide of ToggleChatListCard at 3+ chats blocks visual QA of - // the SupportSimpleXBanner alongside it. Restore before merging. - // if (!oneHandUICardShown.value) { - // LaunchedEffect(chats.size) { - // if (chats.size >= 3) { - // appPrefs.oneHandUICardShown.set(true) - // } - // } - // } + if (!oneHandUICardShown.value) { + LaunchedEffect(chats.size) { + if (chats.size >= 3) { + appPrefs.oneHandUICardShown.set(true) + } + } + } LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index 04b7674cec..daa6905033 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -31,7 +31,43 @@ m20260806_badge_service_schema = servicePrefix [r| ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; + +CREATE TABLE @badge_codes( + badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + code_hash BYTEA NOT NULL, + badge_type TEXT NOT NULL, + months SMALLINT NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(code_hash) +); + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id BIGINT REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); |] down_m20260806_badge_service_schema :: Text -down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [r| +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [r| +DROP TABLE @badge_codes; +|] diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index b0974f177d..d96c779094 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -32,7 +32,43 @@ m20260806_badge_service_schema = servicePrefix [sql| ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; + +CREATE TABLE @badge_codes( + badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT, + code_hash BLOB NOT NULL, + badge_type TEXT NOT NULL, + months INTEGER NOT NULL, + code_payment_status TEXT NOT NULL, + redeemed_at TEXT, + created_at TEXT NOT NULL, + UNIQUE(code_hash) +); + +ALTER TABLE @badge_purchases ADD COLUMN badge_code_id INTEGER REFERENCES @badge_codes; + +CREATE UNIQUE INDEX @idx_badge_purchases_code ON @badge_purchases(badge_code_id); + +CREATE TABLE @badge_code_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES @badge_prices, + offer_id TEXT REFERENCES @badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +); |] down_m20260806_badge_service_schema :: Query -down_m20260806_badge_service_schema = badgeSchemaDown servicePrefix +down_m20260806_badge_service_schema = + withPrefix + servicePrefix + [sql| +DROP TABLE @badge_code_invoices; + +DROP INDEX @idx_badge_purchases_code; +|] + <> badgeSchemaDown servicePrefix + <> withPrefix + servicePrefix + [sql| +DROP TABLE @badge_codes; +|] diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 7ce3b78b34..f3bafec240 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -10,15 +10,16 @@ A request is an envelope: `version` — the client's protocol version; `purchase ## Identity -Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`, and a key it holds no record of with `unknown_purchase_key`. +Each purchase runs under a fresh Ed25519 key pair; `purchaseKey` is its public part and identifies the badge. The service cannot link purchases of one user; the exceptions are the declared upgrades below. `getBadgeCatalog` may omit `purchaseKey`: unsigned, it returns the catalog alone; signed, its response adds the purchase's `badgeStatement` — a client holding a lapsed badge checks for credits in the same request that prices a new purchase, and buys under a fresh key only when the statement shows none. Every other command requires the key and is signed with it. The agent delivers the verified signer key alongside the request; the service rejects a `purchaseKey` that differs from it with `bad_request`. -A purchase record is created by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, `code`, or `receipt`. +A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice`, or by `purchaseBadge` funded with `apple`, `google`, or `receipt`. Those commands accept a key the service holds no record of — on a first purchase it always will. Every other command answers `unknown_purchase_key` for such a key. ## Idempotency A timeout hides the outcome, so the client repeats the identical signed request at its next trigger, never on a poll timer. - `getBadgeInvoice` — returns the open invoice again; a new invoice is created only when none is open. +- `redeemBadgeCode` — a code already redeemed by the signing key returns the same `badgeCredential` and writes nothing; redeemed by another key, `code_used`. The client must therefore keep the key it first signed with, or a retry cannot be recognised. - `purchaseBadge` — a payment already credited returns the same `badgeCredential` and writes nothing. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. @@ -30,7 +31,8 @@ A timeout hides the outcome, so the client repeats the identical signed request - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. -- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `code`; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. +- `redeemBadgeCode` → `badgeCredential` — redeems a code, records the credit, and issues the first credential, in one round trip. It carries `masterKey` and `code` and no `badgeRequest`: a code states no tier and no expiry, so the credential is what reports them. Errors: `code_invalid` for an unknown or malformed code, `code_used` when another key redeemed it, `code_expired` past a redemption deadline. +- `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. - `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. - `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). @@ -61,4 +63,4 @@ An assertion that names an entry the service holds is a prefix: the service proc ## Errors -`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown and revoked codes; `code_used` — redeemed under another key. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. +`retryAfter` marks the transient codes: `payment_pending`, `provider_unavailable`, `rate_limited`. `offer_disabled` calls for a catalog refresh. `code_invalid` covers unknown, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — redeemed under another key; `code_expired` — past its redemption deadline. `receipt_invalid` covers unknown receipts. All other codes are terminal for the attempted command. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 00ecc69874..6a3b2f81d4 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -102,9 +102,6 @@ "invoice": { "properties": {"invoiceId": {"type": "string"}} }, - "code": { - "properties": {"code": {"type": "string"}} - }, "receipt": { "properties": { "receipt": { @@ -298,6 +295,12 @@ } } }, + "redeemBadgeCode": { + "properties": { + "masterKey": {"ref": "base64url"}, + "code": {"type": "string"} + } + }, "purchaseBadge": { "properties": { "badgeRequest": {"ref": "badgeRequest"}, diff --git a/plans/2026-08-27-badges-mvp-streams.md b/plans/2026-08-27-badges-mvp-streams.md new file mode 100644 index 0000000000..444ad73f91 --- /dev/null +++ b/plans/2026-08-27-badges-mvp-streams.md @@ -0,0 +1,194 @@ +# Supporter badges — beta slice and stream plan + +**2026-08-27** · protocol `docs/protocol/badges-rpc.{md,schema.json}` · model and copy rules `plans/2026-07-30-supporter-badges-v3-ux.md` · core API `plans/2026-07-31-badges-core-implementation.md`. The product plan's §4 wire protocol was superseded before implementation; `badges-rpc.md` is the protocol. + +--- + +## 1. The slice + +Beta ships one route to a badge: **buy a code in a browser, redeem it in the app.** The app asks no purchase questions — tier, duration and method are the site's. + +Deferred, still in the protocol: in-app invoices, store purchase, subscriptions, upgrades, transfers, pause, alerts. + +**First, no dependencies:** divert the apps' purchase path so nothing reaches a store charge — it takes real money today and issues no badge. The views and the store code stay in place, uncalled. + +Accepted: a code is a bearer instrument. Whoever sees it can spend it, and this slice has no revocation. + +## 2. State on `badges` + +Other branches may carry work this does not see. + +- **Written** — credential signing, verification, proofs, status, presentation; protocol types, RPC docs, JTD schema; client and service schema incl. this plan's tables; app banner, settings entry, what's-new +- **Scaffold** — badge service answers every request `unsupported_version` +- **Not written** — JSON instances for most protocol types; core client badge API, store, worker; checkout site and providers; app redeem view (title-only stub), badge-state screen, browser hand-off +- **To divert** — the app's tier/duration screens, which today lead to a store purchase + +Registering the client migration needs `STRICT` on all ten SQLite tables; Postgres must not have it. + +--- + +## 3. Contracts between streams + +**The code** — `SXB-` + 20 Crockford chars in four groups, one of them a check character. Normalisation upper-cases and folds `I`/`L`→`1`, `O`→`0`, identically both sides. Only `SHA-256` of the normalised code is stored anywhere. Site codes are `HMAC(secret, orderId)`; operator codes are random. + +**`redeemBadgeCode`** over service RPC — signed with an Ed25519 key generated per code and reused by a retry of it. Carries the badge master key and the code, nothing else. Returns the signed credential and a **statement**, or `code_invalid` | `code_used` | `code_expired` | `rate_limited` | `internal`. + +A *statement* is an extract of the badge's ledger — the months balance, as a list of entries. The ledger is authored by the service alone; the client keeps a verbatim replica and reads its balance from the last entry. + +**The catalog** — one source in the service, one total function. The site renders those totals; the browser never multiplies a price by a month count. The app does not read it this release. + +The code is the only thing crossing site → app. The site never sees a purchase key, credential or ledger; the app never sees an order. + +--- + +## 4. Stream 1 — codes for badges + +### 4.1 Redemption becomes its own command + +`purchaseBadge {badgeRequest, payment:{type:"code"}}` asks the caller for a tier and expiry it cannot know and the service must override. + +- `redeemBadgeCode {masterKey, code}` → `badgeCredential {credential, receipt?, statement}` +- `code` leaves the payment union; `purchaseBadge` keeps the rest — `apple`, `google`, `invoice`, `receipt` — and stays unimplemented +- the tier is stated by the credential, which the client verifies +- client command `APIRedeemBadgeCode {userId, code}` + +One thing here fails quietly. The request envelope carries an optional `purchaseKey`, and the service decides per command whether that key must already name a purchase — most require it and answer `unknown_purchase_key` otherwise. `redeemBadgeCode` is the exception: it *creates* the purchase, so on a first redemption the key is always unknown. Miss the special case and the lookup falls through to the default, every first redemption is refused, and nothing fails to compile because it is a runtime lookup. Worth its own test. + +### 4.2 Schema + +The tables, in layers: + +- **money** — `invoices` (an amount owed), `payments` (an amount paid), `subscription_charges`. Generic: they carry no idea what was bought. +- **catalog** — `badge_prices` (tier → price per month), `badge_offers` (duration discounts). +- **what an invoice bought** — `badge_invoices` for a purchase, `badge_code_invoices` for a voucher. Same shape, except the second names no purchase: at the time of sale none exists, and the buyer may never be the redeemer. +- **the badge** — `badge_purchases` is the anchor: keys, tier, status, and what funded it — `payment_id` for an in-app purchase, or a code. The code column is added separately on each side, each pointing at its own code table. Its balance is `badge_ledger`, its credentials `badge_issuances`, and `users.shown_badge_id` names the one on show. +- **the voucher** — `badge_codes`, service only: the hash, the tier and months it is worth, and `code_payment_status` — paid, unpaid or free — so a minted code is told from a sold one without joining invoices. Never a badge itself. +- **the attempt** — `badge_code_redemptions`, client only. It holds the code in plaintext, because the client sends the code and not its hash, and the keys the redemption is signed with, so a retry can be the same signer (§4.3). + +The beta path: the site writes an invoice and what it bought, then a code. The app redeems that code, which creates the purchase and its first issuance — and, from milestone D, its ledger. + +New here are `badge_code_redemptions` (client) and `badge_codes`, `badge_code_invoices` (service); all are in the migration modules already. Every schema change touches four files — the shared block, the client-only section and the service migrations each exist twice, SQLite and Postgres, unlinked by the build. + +### 4.3 Retry is safe + +A redemption is signed with a key the client generates for it. If that key is generated fresh on every attempt, a timeout is unrecoverable: the service may already have redeemed the code, and the retry arrives as a *different* signer, so it reads as someone else presenting a spent code — `code_used`. The user has paid and cannot get the badge. + +The fix is for a retry to be the **same** signer: + +- the service's replay keys on **(code hash, verified signer)**: the same code from the same key returns the credential it already issued and writes nothing +- so the client writes the signing keys into a `badge_code_redemptions` row **before** sending, found by the code, and a retry reads them back + +That row is a stash for the in-flight attempt. Its fate depends on the outcome: + +| outcome | the row | +|---|---| +| success | kept, and pointed at by the new purchase | +| terminal error — `code_invalid`, `code_used`, `code_expired` | deleted: the code will never work, so the keys are dead | +| timeout | **kept** — this is the case it exists for | + +The keys need a row of their own because **code redemption cannot create its `badge_purchases` row up front.** That row's badge-type columns are `NOT NULL` and a code carries no tier, so there is nowhere to put them until the service answers. An in-app purchase has no such problem — the tier was picked on screen, so it creates its purchase in `acquiring` immediately. + +### 4.4 Service + +Store layer, ledger transitions, credential signing, code minting and classification, RPC dispatcher, then the redeem and issue handlers. + +**Nothing is written until the credential is signed.** Look the code up, compute the ledger changes in memory, sign — and only then open one transaction that writes the purchase, the ledger rows, the issuance and the redemption together. Signing is the step most likely to fail for reasons unrelated to the request. If the code were marked redeemed first, a signing failure would leave it spent with no credential behind it: dead, and revivable only by an operator. Signing first means a failure touches nothing and the user can simply try again. + +**Errors say as little as possible.** An unknown code and a malformed one both answer `code_invalid`, so someone guessing learns nothing from the difference. A code already redeemed answers `code_used` — but only to a *different* key; the key that redeemed it gets its credential back (§4.3). + +### 4.5 Core client + +Badge store, commands and events, re-issue worker, redeem path. + +- verify the credential against the configured issuer keys **before** writing anything +- copy the statement's entries into the ledger replica exactly as received — never compute a balance locally, never edit or invent an entry; one author means client and service cannot disagree +- on success, one transaction: write the purchase and its issuance, copy the ledger entries, complete the code row, retire the profile's previous badge of the same kind, and point the profile at the new one — then release the lock and present to contacts +- split `addUserBadge` first — it verifies, stores and broadcasts under the global chat lock in one function and raises command errors; the redeem path needs a per-user lock and a service error code + +A profile shows one badge at a time and holds at most two: a paid one and an investor one. Redeeming a code fills that kind's place, and the purchase that was there moves to `superseded`. Its unspent months stay with it — purchases are unlinkable, so nothing can move a balance between them. That matters for **Add more months** (§4.6): a second code starts a new balance rather than topping up the old one. + +### 4.6 Apps + +- *Support SimpleX* — **Get the code** (opens the site; absent on store builds) and **Redeem the code** (everywhere) +- *Redeem code* — formats as typed, folds ambiguous characters, verifies the check character before sending, one message per service error +- *Supporter perks* — the badge, that it is shown, the date support **ends**, **Add more months**; plus the ended state +- **Diverted** — tier and duration screens leave the flow, kept compiled and uncalled; store product loading and the catalog command lose their last callers +- **Copy** — one date, the paid-through date from the ledger, never the credential's expiry; *ends*, never *renews* + +### 4.7 Issuing without selling + +Compensation codes need no new mechanism: minted by the operator, random rather than derived, printed once, stored as hashes. + +Investor badges are not a special case: an operator mints a code of that tier and it is redeemed by the same path as any other. `code_payment_status` records that it was minted rather than sold; the redemption path never reads it. + +A badge that never expires is a separate question, and deferred — the ledger holds a month count, so "forever" has no representation. Until it does, a long finite term serves: the count is a byte, so twenty years is expressible. + +### 4.8 Order of work + +**The ledger is stubbed until last** — redemption issues one credential and writes no ledger rows, and the statement comes back empty. Everything before D is a working badge without accounting. + +**First, independent:** divert the apps' purchase path (§1). + +**A — core, redeemable from the CLI.** Migration registered with `STRICT` and both dumps regenerated; code format in the shared library; `redeemBadgeCode` types, JSON, schema and docs; service store, signing, dispatcher and redeem handler; a minimal mint command. + +`APIRedeemBadgeCode` lands here **with its `chatCommandP` parser and `View.hs` rendering**, so a code can be redeemed from the terminal with no app involved. That is what proves the round trip — RPC delivery, signing, credential verification, the profile update — before any UI exists to confuse the picture, and it stays the fastest way to reproduce a redemption afterwards. + +Tests land here too, not later: `tests/Bots/BadgeServiceTests.hs` already starts the service in-process and hands a chat client its address, so a mint-then-redeem case is an extension of the existing harness rather than new scaffolding. Cover the code redeeming into a badge, and an unknown code answering `code_invalid`. That spec sits under `xdescribe''`, which skips when `CI` is set — so it must be run locally; CI green is not evidence for it. + +*Done when* a code minted by that command and redeemed from the terminal puts a badge on the profile and contacts see it, and those tests pass locally. + +**B — apps.** Support screen with its two actions, redeem screen, badge-state screen, copy. Both platforms in step. *Done when* the same works by pasting a code into the app. + +**C — unhappy paths.** Retry idempotency — keys stashed before sending, replay keyed on (code hash, signer). Error mapping for `code_invalid`, `code_used`, `code_expired` and a locally failed verification. + +**D — ledger.** Transitions, credit on redemption, debit on issue, lapse; statement in the response; client replica; monthly re-issue worker. *Done when* a three-month code re-issues at the month boundary and both ledgers match row for row. Property tests here: balance never below zero, issuance debits exactly one month, lapse removes only elapsed unissued months. + +### 4.9 Done means + +- an operator-minted code redeems on desktop, Android and iOS and shows on the profile and in member lists +- the same code twice from one profile returns the same badge and consumes nothing +- a timeout then a retry issues exactly one badge, no operator involved +- every service error renders inline, as does a locally failed credential verification +- an operator-minted code redeems by the same path as one bought on the site, whatever its tier +- client and service ledger rows match row for row; a crossed month boundary re-issues without a restart +- redeeming a code writes no payment row + +--- + +## 5. Stream 2 — selling codes on the web + +Takes money and produces codes: a page, card and crypto payment, and whatever server side that needs. Its only output that stream 1 consumes is a code. + +**What it owes stream 1** + +- codes in §3's format and normalisation, with only their `SHA-256` stored +- prices from the service catalog — one source and one total function, so the site and the app cannot disagree +- `badge_code_invoices` filled with what each invoice bought; the table is already in the schema + +**Its own to settle.** Hosting and deployment, endpoints, which providers, how the browser learns an order settled, how long a code stays retrievable after purchase, and what reference support resolves against. None of it reaches stream 1. + +**Before the tier page ships:** it advertises a storage-duration perk — confirm that perk exists (§6) or drop the claim. + +--- + +## 6. Stream 3 — perks + +What an active badge changes for the user: XFTP file size and storage duration, granted against the badge proof the sender presents. + +Integration points, in dependency order: + +1. a presentation context binding a proof to the operation it authorises, so a proof lifted from a profile cannot authorise an upload *(client)* +2. recipient-side size verification against the presented proof *(client)* +3. carrying the proof to the file server on upload *(simplexmq)* +4. issuer keys in file-server config, so it can verify one *(simplexmq)* +5. per-file size and retention derived from the verified badge type *(simplexmq)* + +1 and 2 are client-side and independent of the rest. Target values: supporter 2GB and 7 days, legend 5GB and 21 days. + +--- + +## 7. Ledger — for information + +The months accounting, and a mechanism inside stream 1 rather than a stream of its own: the service authors every entry, the client keeps a verbatim replica and reads the balance from the last one. + +Beta needs three operations — credit on redemption, debit on issue, lapse on elapsed months — which is milestone D. The rest of the ledger's operations arrive with the features that need them. diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 6f15d0fa16..ff06fb7d94 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -62,6 +62,10 @@ data BadgeServiceCommand paymentVia :: ServicePaymentMethod, upgrade :: Maybe BadgeUpgrade -- upgrade non-store badge } + | BSCRedeemBadgeCode + { masterKey :: BadgeMasterKey, + code :: Text -- no badgeRequest: a code carries no tier for the client to state + } | BSCPurchaseBadge { badgeRequest :: BadgeRequest, payment :: ServicePayment, diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 70dd4dfa8c..c5289504f0 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -9,6 +9,7 @@ module Simplex.Chat.Badges.Types BadgeItemStatus (..), OfferDiscount (..), BadgePurchaseStatus (..), + BadgeCodePaymentStatus (..), LedgerEntryType (..), LedgerCreditType (..), LedgerDebitType (..), @@ -22,7 +23,6 @@ module Simplex.Chat.Badges.Types ) where import qualified Data.Aeson as J -import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) @@ -58,6 +58,10 @@ data OfferDiscount data BadgePurchaseStatus = PSAcquiring | PSIssued | PSSuperseded | PSFailed deriving (Eq, Show) +-- unconfirmed draft +data BadgeCodePaymentStatus = CPSPaid | CPSUnpaid | CPSFree + deriving (Eq, Show) + -- confirmed data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit :: LedgerDebitType} deriving (Eq, Show) diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs index a4484c469c..4cbfc0ef11 100644 --- a/src/Simplex/Chat/PaymentService.hs +++ b/src/Simplex/Chat/PaymentService.hs @@ -26,6 +26,5 @@ data ServicePayment = SPApple {jws :: Text} | SPGoogle {token :: Text} | SPInvoice {invoiceId :: InvoiceId} - | SPCode {code :: Text} | SPReceipt {receipt :: Text} -- transfer of unissued months deriving (Show) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs index 30908cdaf8..fd3024966d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs @@ -180,12 +180,23 @@ CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id badgeSchemaTablesDown :: Text badgeSchemaTablesDown = [r| +DROP INDEX @idx_badge_issuances_purchase; DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; DROP TABLE @badge_ledger; +DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; DROP TABLE @badge_offers; @@ -217,11 +228,34 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BYTEA NOT NULL, + purchase_priv_key BYTEA NOT NULL, + master_key BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(code) +); + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id BIGINT REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] down_m20261001_user_badges :: Text down_m20261001_user_badges = [r| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" + <> [r| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs index f1015ae393..94b2fbc41e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs @@ -181,12 +181,23 @@ CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id badgeSchemaTablesDown :: Query badgeSchemaTablesDown = [sql| +DROP INDEX @idx_badge_issuances_purchase; DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP INDEX @idx_badge_ledger_payment; +DROP INDEX @idx_badge_ledger_charge; +DROP INDEX @idx_badge_ledger_from_purchase; +DROP INDEX @idx_badge_ledger_to_purchase; DROP TABLE @badge_ledger; +DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; +DROP INDEX @idx_badge_invoices_purchase; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; DROP TABLE @badge_offers; @@ -218,11 +229,34 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; + +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(code) +); + +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); + +ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions; + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] down_m20261001_user_badges :: Query down_m20261001_user_badges = [sql| +DROP INDEX idx_badge_purchases_code_redemption; +DROP INDEX idx_badge_purchases_user; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" + <> [sql| +DROP INDEX idx_badge_code_redemptions_user; +DROP TABLE badge_code_redemptions; +|] From 6075ea579731fd859beea76eb0579526b2c0cd00 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:20:13 +0000 Subject: [PATCH 08/16] core: register badges migration (#7429) --- .../BadgeService/Store/Postgres/Migrations.hs | 6 + .../BadgeService/Store/SQLite/Migrations.hs | 10 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20261001_user_badges.hs | 15 + .../Store/Postgres/Migrations/chat_schema.sql | 462 +++++++++++++++++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20261001_user_badges.hs | 37 +- .../Store/SQLite/Migrations/chat_schema.sql | 180 ++++++- 8 files changed, 700 insertions(+), 18 deletions(-) diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index daa6905033..18d589b037 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -54,6 +54,10 @@ CREATE TABLE @badge_code_invoices( months SMALLINT NOT NULL, created_at TIMESTAMPTZ NOT NULL ); + +CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); + +CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); |] down_m20260806_badge_service_schema :: Text @@ -61,6 +65,8 @@ down_m20260806_badge_service_schema = withPrefix servicePrefix [r| +DROP INDEX @idx_badge_code_invoices_offer; +DROP INDEX @idx_badge_code_invoices_price; DROP TABLE @badge_code_invoices; DROP INDEX @idx_badge_purchases_code; diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index d96c779094..2851813c8c 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -42,7 +42,7 @@ CREATE TABLE @badge_codes( redeemed_at TEXT, created_at TEXT NOT NULL, UNIQUE(code_hash) -); +) STRICT; ALTER TABLE @badge_purchases ADD COLUMN badge_code_id INTEGER REFERENCES @badge_codes; @@ -54,7 +54,11 @@ CREATE TABLE @badge_code_invoices( offer_id TEXT REFERENCES @badge_offers, months INTEGER NOT NULL, created_at TEXT NOT NULL -); +) STRICT; + +CREATE INDEX @idx_badge_code_invoices_offer ON @badge_code_invoices(offer_id); + +CREATE INDEX @idx_badge_code_invoices_price ON @badge_code_invoices(price_id); |] down_m20260806_badge_service_schema :: Query @@ -62,6 +66,8 @@ down_m20260806_badge_service_schema = withPrefix servicePrefix [sql| +DROP INDEX @idx_badge_code_invoices_offer; +DROP INDEX @idx_badge_code_invoices_price; DROP TABLE @badge_code_invoices; DROP INDEX @idx_badge_purchases_code; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a8b4958de4..862a604da3 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -48,6 +48,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link +import Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -95,7 +96,8 @@ schemaMigrations = ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), - ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), + ("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs index fd3024966d..0aa9153b2c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs @@ -89,6 +89,8 @@ CREATE TABLE @badge_offers( created_at TIMESTAMPTZ NOT NULL ); +CREATE INDEX @idx_badge_offers_price ON @badge_offers(price_id); + CREATE TABLE @badge_purchases( badge_purchase_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, purchase_key BYTEA NOT NULL, @@ -114,6 +116,10 @@ CREATE TABLE @badge_invoices( CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); +CREATE INDEX @idx_badge_invoices_offer ON @badge_invoices(offer_id); + +CREATE INDEX @idx_badge_invoices_price ON @badge_invoices(price_id); + CREATE TABLE @badge_subscription_changes( change_id TEXT NOT NULL PRIMARY KEY, badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -175,12 +181,15 @@ CREATE TABLE @badge_issuances( ); CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); |] badgeSchemaTablesDown :: Text badgeSchemaTablesDown = [r| DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; DROP TABLE @badge_issuances; DROP INDEX @idx_badge_ledger_uuid; DROP INDEX @idx_badge_ledger_purchase; @@ -192,6 +201,8 @@ DROP TABLE @badge_ledger; DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; DROP INDEX @idx_badge_invoices_purchase; +DROP INDEX @idx_badge_invoices_offer; +DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; @@ -199,6 +210,7 @@ DROP INDEX @idx_payments_provider_ref; DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; DROP TABLE @badge_offers; DROP TABLE @badge_prices; |] @@ -229,6 +241,8 @@ CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; +CREATE INDEX idx_users_shown_badge ON users(shown_badge_id); + CREATE TABLE badge_code_redemptions( badge_code_redemption_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, @@ -252,6 +266,7 @@ down_m20261001_user_badges = [r| DROP INDEX idx_badge_purchases_code_redemption; DROP INDEX idx_badge_purchases_user; +DROP INDEX idx_users_shown_badge; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ab6384cb29..fbc663f45d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -168,6 +168,159 @@ CREATE TABLE test_chat_schema.app_settings ( +CREATE TABLE test_chat_schema.badge_code_redemptions ( + badge_code_redemption_id bigint NOT NULL, + user_id bigint NOT NULL, + code text NOT NULL, + purchase_key bytea NOT NULL, + purchase_priv_key bytea NOT NULL, + master_key bytea NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +ALTER TABLE test_chat_schema.badge_code_redemptions ALTER COLUMN badge_code_redemption_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_code_redemptions_badge_code_redemption_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.badge_invoices ( + invoice_id text NOT NULL, + badge_purchase_id bigint NOT NULL, + price_id text NOT NULL, + offer_id text, + months smallint NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_issuances ( + issuance_id text NOT NULL, + badge_purchase_id bigint NOT NULL, + entry_id bigint, + badge_type text NOT NULL, + period_start timestamp with time zone NOT NULL, + period_end timestamp with time zone NOT NULL, + expiry timestamp with time zone NOT NULL, + credential bytea NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_ledger ( + entry_id bigint NOT NULL, + entry_uuid text NOT NULL, + badge_purchase_id bigint NOT NULL, + change_months smallint NOT NULL, + balance_months smallint NOT NULL, + balance_start_ts timestamp with time zone NOT NULL, + balance_badge_type text NOT NULL, + was_paused_since timestamp with time zone, + service_created_at timestamp with time zone NOT NULL, + created_at timestamp with time zone NOT NULL, + entry_type text NOT NULL, + entry_credit_type text, + entry_debit_type text, + payment_id text, + charge_id text, + from_purchase_id bigint, + to_purchase_id bigint, + entry_type_unknown smallint DEFAULT 0 NOT NULL, + entry_type_value text +); + + + +ALTER TABLE test_chat_schema.badge_ledger ALTER COLUMN entry_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_ledger_entry_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.badge_offers ( + offer_id text NOT NULL, + price_id text, + months smallint NOT NULL, + free_months smallint, + discount smallint, + status text NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_prices ( + price_id text NOT NULL, + badge_type text NOT NULL, + month_price bigint NOT NULL, + currency text NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL +); + + + +CREATE TABLE test_chat_schema.badge_purchases ( + badge_purchase_id bigint NOT NULL, + purchase_key bytea NOT NULL, + master_key bytea NOT NULL, + initial_badge_type text NOT NULL, + current_badge_type text NOT NULL, + payment_id text, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + user_id bigint, + purchase_priv_key bytea, + alert_acked_kind text, + alert_acked_episode text, + alert_snooze_until timestamp with time zone, + badge_code_redemption_id bigint +); + + + +ALTER TABLE test_chat_schema.badge_purchases ALTER COLUMN badge_purchase_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.badge_purchases_badge_purchase_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.badge_subscription_changes ( + change_id text NOT NULL, + badge_purchase_id bigint NOT NULL, + from_badge_type text NOT NULL, + to_badge_type text NOT NULL, + from_provider_ref text, + to_provider_ref text, + effective text NOT NULL, + status text NOT NULL, + effective_at timestamp with time zone, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + CREATE TABLE test_chat_schema.calls ( call_id bigint NOT NULL, contact_id bigint NOT NULL, @@ -999,7 +1152,7 @@ CREATE TABLE test_chat_schema.groups ( public_member_count bigint, relay_request_retries bigint DEFAULT 0 NOT NULL, relay_request_delay bigint DEFAULT 0 NOT NULL, - relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL, + relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL, relay_inactive_at timestamp with time zone, relay_sent_web_domain text, roster_version bigint, @@ -1027,6 +1180,26 @@ ALTER TABLE test_chat_schema.groups ALTER COLUMN group_id ADD GENERATED ALWAYS A +CREATE TABLE test_chat_schema.invoices ( + invoice_id text NOT NULL, + provider text NOT NULL, + price bigint NOT NULL, + discount_amount bigint, + credit_amount bigint, + amount bigint NOT NULL, + currency text NOT NULL, + payment_url text, + payment_address text, + payment_crypto_currency text, + payment_crypto_amount text, + expires_at timestamp with time zone NOT NULL, + status text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + + CREATE TABLE test_chat_schema.known_servers ( server_id bigint NOT NULL, host text NOT NULL, @@ -1160,6 +1333,26 @@ ALTER TABLE test_chat_schema.operator_usage_conditions ALTER COLUMN operator_usa +CREATE TABLE test_chat_schema.payments ( + payment_id text NOT NULL, + invoice_id text, + provider text NOT NULL, + provider_ref text, + amount bigint, + currency text, + status text NOT NULL, + exception text, + subscription_renews_at timestamp with time zone, + grace_until timestamp with time zone, + cancelled smallint DEFAULT 0 NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + evidence bytea, + receipt_code text +); + + + CREATE TABLE test_chat_schema.pending_group_messages ( pending_group_message_id bigint NOT NULL, group_member_id bigint NOT NULL, @@ -1454,6 +1647,19 @@ CREATE TABLE test_chat_schema.snd_files ( +CREATE TABLE test_chat_schema.subscription_charges ( + charge_id text NOT NULL, + payment_id text NOT NULL, + provider_charge_ref text NOT NULL, + period_start timestamp with time zone NOT NULL, + period_end timestamp with time zone NOT NULL, + amount bigint NOT NULL, + currency text NOT NULL, + charged_at timestamp with time zone NOT NULL +); + + + CREATE TABLE test_chat_schema.usage_conditions ( usage_conditions_id bigint NOT NULL, conditions_commit text NOT NULL, @@ -1516,7 +1722,8 @@ CREATE TABLE test_chat_schema.users ( auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, client_service smallint DEFAULT 0 NOT NULL, - auto_accept_group_invitations smallint DEFAULT 0 NOT NULL + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL, + shown_badge_id bigint ); @@ -1555,6 +1762,61 @@ ALTER TABLE test_chat_schema.xftp_file_descriptions ALTER COLUMN file_descr_id A +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_code_key UNIQUE (code); + + + +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_invoices + ADD CONSTRAINT badge_invoices_pkey PRIMARY KEY (invoice_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_pkey PRIMARY KEY (issuance_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_pkey PRIMARY KEY (entry_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_offers + ADD CONSTRAINT badge_offers_pkey PRIMARY KEY (offer_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_prices + ADD CONSTRAINT badge_prices_pkey PRIMARY KEY (price_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_payment_id_key UNIQUE (payment_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_pkey PRIMARY KEY (badge_purchase_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_purchase_key_key UNIQUE (purchase_key); + + + +ALTER TABLE ONLY test_chat_schema.badge_subscription_changes + ADD CONSTRAINT badge_subscription_changes_pkey PRIMARY KEY (change_id); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_pkey PRIMARY KEY (call_id); @@ -1745,6 +2007,11 @@ ALTER TABLE ONLY test_chat_schema.groups +ALTER TABLE ONLY test_chat_schema.invoices + ADD CONSTRAINT invoices_pkey PRIMARY KEY (invoice_id); + + + ALTER TABLE ONLY test_chat_schema.known_servers ADD CONSTRAINT known_servers_pkey PRIMARY KEY (server_id); @@ -1780,6 +2047,11 @@ ALTER TABLE ONLY test_chat_schema.operator_usage_conditions +ALTER TABLE ONLY test_chat_schema.payments + ADD CONSTRAINT payments_pkey PRIMARY KEY (payment_id); + + + ALTER TABLE ONLY test_chat_schema.pending_group_messages ADD CONSTRAINT pending_group_messages_pkey PRIMARY KEY (pending_group_message_id); @@ -1855,6 +2127,16 @@ ALTER TABLE ONLY test_chat_schema.snd_files +ALTER TABLE ONLY test_chat_schema.subscription_charges + ADD CONSTRAINT subscription_charges_payment_id_provider_charge_ref_key UNIQUE (payment_id, provider_charge_ref); + + + +ALTER TABLE ONLY test_chat_schema.subscription_charges + ADD CONSTRAINT subscription_charges_pkey PRIMARY KEY (charge_id); + + + ALTER TABLE ONLY test_chat_schema.usage_conditions ADD CONSTRAINT usage_conditions_conditions_commit_key UNIQUE (conditions_commit); @@ -1907,6 +2189,70 @@ CREATE INDEX files_note_folder_id ON test_chat_schema.files USING btree (note_fo +CREATE INDEX idx_badge_code_redemptions_user ON test_chat_schema.badge_code_redemptions USING btree (user_id); + + + +CREATE INDEX idx_badge_invoices_offer ON test_chat_schema.badge_invoices USING btree (offer_id); + + + +CREATE INDEX idx_badge_invoices_price ON test_chat_schema.badge_invoices USING btree (price_id); + + + +CREATE INDEX idx_badge_invoices_purchase ON test_chat_schema.badge_invoices USING btree (badge_purchase_id); + + + +CREATE INDEX idx_badge_issuances_entry ON test_chat_schema.badge_issuances USING btree (entry_id); + + + +CREATE INDEX idx_badge_issuances_purchase ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, issuance_id); + + + +CREATE INDEX idx_badge_ledger_charge ON test_chat_schema.badge_ledger USING btree (charge_id); + + + +CREATE INDEX idx_badge_ledger_from_purchase ON test_chat_schema.badge_ledger USING btree (from_purchase_id); + + + +CREATE INDEX idx_badge_ledger_payment ON test_chat_schema.badge_ledger USING btree (payment_id); + + + +CREATE INDEX idx_badge_ledger_purchase ON test_chat_schema.badge_ledger USING btree (badge_purchase_id, entry_id); + + + +CREATE INDEX idx_badge_ledger_to_purchase ON test_chat_schema.badge_ledger USING btree (to_purchase_id); + + + +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON test_chat_schema.badge_ledger USING btree (entry_uuid); + + + +CREATE INDEX idx_badge_offers_price ON test_chat_schema.badge_offers USING btree (price_id); + + + +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON test_chat_schema.badge_purchases USING btree (badge_code_redemption_id); + + + +CREATE INDEX idx_badge_purchases_user ON test_chat_schema.badge_purchases USING btree (user_id); + + + +CREATE INDEX idx_badge_subscription_changes_purchase ON test_chat_schema.badge_subscription_changes USING btree (badge_purchase_id); + + + CREATE INDEX idx_calls_chat_item_id ON test_chat_schema.calls USING btree (chat_item_id); @@ -2511,6 +2857,14 @@ CREATE INDEX idx_operator_usage_conditions_server_operator_id ON test_chat_schem +CREATE INDEX idx_payments_invoice ON test_chat_schema.payments USING btree (invoice_id); + + + +CREATE INDEX idx_payments_provider_ref ON test_chat_schema.payments USING btree (provider, provider_ref); + + + CREATE INDEX idx_pending_group_messages_group_member_id ON test_chat_schema.pending_group_messages USING btree (group_member_id); @@ -2639,6 +2993,10 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con +CREATE INDEX idx_users_shown_badge ON test_chat_schema.users USING btree (shown_badge_id); + + + CREATE INDEX idx_xftp_file_descriptions_user_id ON test_chat_schema.xftp_file_descriptions USING btree (user_id); @@ -2659,6 +3017,91 @@ CREATE TRIGGER tr_group_members_update_update_summary AFTER UPDATE ON test_chat_ +ALTER TABLE ONLY test_chat_schema.badge_code_redemptions + ADD CONSTRAINT badge_code_redemptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_invoices + ADD CONSTRAINT badge_invoices_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_invoices + ADD CONSTRAINT badge_invoices_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES test_chat_schema.invoices(invoice_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_invoices + ADD CONSTRAINT badge_invoices_offer_id_fkey FOREIGN KEY (offer_id) REFERENCES test_chat_schema.badge_offers(offer_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_invoices + ADD CONSTRAINT badge_invoices_price_id_fkey FOREIGN KEY (price_id) REFERENCES test_chat_schema.badge_prices(price_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_issuances + ADD CONSTRAINT badge_issuances_entry_id_fkey FOREIGN KEY (entry_id) REFERENCES test_chat_schema.badge_ledger(entry_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_charge_id_fkey FOREIGN KEY (charge_id) REFERENCES test_chat_schema.subscription_charges(charge_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_from_purchase_id_fkey FOREIGN KEY (from_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_ledger + ADD CONSTRAINT badge_ledger_to_purchase_id_fkey FOREIGN KEY (to_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_offers + ADD CONSTRAINT badge_offers_price_id_fkey FOREIGN KEY (price_id) REFERENCES test_chat_schema.badge_prices(price_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_badge_code_redemption_id_fkey FOREIGN KEY (badge_code_redemption_id) REFERENCES test_chat_schema.badge_code_redemptions(badge_code_redemption_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id); + + + +ALTER TABLE ONLY test_chat_schema.badge_purchases + ADD CONSTRAINT badge_purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.badge_subscription_changes + ADD CONSTRAINT badge_subscription_changes_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -3199,6 +3642,11 @@ ALTER TABLE ONLY test_chat_schema.operator_usage_conditions +ALTER TABLE ONLY test_chat_schema.payments + ADD CONSTRAINT payments_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES test_chat_schema.invoices(invoice_id); + + + ALTER TABLE ONLY test_chat_schema.pending_group_messages ADD CONSTRAINT pending_group_messages_group_member_id_fkey FOREIGN KEY (group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; @@ -3304,6 +3752,11 @@ ALTER TABLE ONLY test_chat_schema.snd_files +ALTER TABLE ONLY test_chat_schema.subscription_charges + ADD CONSTRAINT subscription_charges_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.user_contact_links ADD CONSTRAINT user_contact_links_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; @@ -3314,6 +3767,11 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links +ALTER TABLE ONLY test_chat_schema.users + ADD CONSTRAINT users_shown_badge_id_fkey FOREIGN KEY (shown_badge_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE SET NULL; + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index de5acc3c82..d420af7f4b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -171,6 +171,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link +import Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -341,7 +342,8 @@ schemaMigrations = ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), - ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), + ("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs index 94b2fbc41e..af91e24af0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs @@ -37,7 +37,7 @@ CREATE TABLE @invoices( status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL -); +) STRICT; CREATE TABLE @payments( payment_id TEXT NOT NULL PRIMARY KEY, @@ -53,7 +53,7 @@ CREATE TABLE @payments( cancelled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL -); +) STRICT; CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); @@ -69,7 +69,7 @@ CREATE TABLE @subscription_charges( currency TEXT NOT NULL, charged_at TEXT NOT NULL, UNIQUE(payment_id, provider_charge_ref) -); +) STRICT; CREATE TABLE @badge_prices( price_id TEXT NOT NULL PRIMARY KEY, @@ -78,7 +78,7 @@ CREATE TABLE @badge_prices( currency TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL -); +) STRICT; CREATE TABLE @badge_offers( offer_id TEXT NOT NULL PRIMARY KEY, @@ -88,7 +88,9 @@ CREATE TABLE @badge_offers( discount INTEGER, status TEXT NOT NULL, created_at TEXT NOT NULL -); +) STRICT; + +CREATE INDEX @idx_badge_offers_price ON @badge_offers(price_id); CREATE TABLE @badge_purchases( badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -102,7 +104,7 @@ CREATE TABLE @badge_purchases( updated_at TEXT NOT NULL, UNIQUE(purchase_key), UNIQUE(payment_id) -); +) STRICT; CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, @@ -111,10 +113,14 @@ CREATE TABLE @badge_invoices( offer_id TEXT REFERENCES @badge_offers, months INTEGER NOT NULL, created_at TEXT NOT NULL -); +) STRICT; CREATE INDEX @idx_badge_invoices_purchase ON @badge_invoices(badge_purchase_id); +CREATE INDEX @idx_badge_invoices_offer ON @badge_invoices(offer_id); + +CREATE INDEX @idx_badge_invoices_price ON @badge_invoices(price_id); + CREATE TABLE @badge_subscription_changes( change_id TEXT NOT NULL PRIMARY KEY, badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -127,7 +133,7 @@ CREATE TABLE @badge_subscription_changes( effective_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL -); +) STRICT; CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); @@ -149,7 +155,7 @@ CREATE TABLE @badge_ledger( charge_id TEXT REFERENCES @subscription_charges, from_purchase_id INTEGER REFERENCES @badge_purchases, to_purchase_id INTEGER REFERENCES @badge_purchases -); +) STRICT; CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); @@ -173,15 +179,18 @@ CREATE TABLE @badge_issuances( expiry TEXT NOT NULL, credential BLOB NOT NULL, created_at TEXT NOT NULL -); +) STRICT; CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); |] badgeSchemaTablesDown :: Query badgeSchemaTablesDown = [sql| DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; DROP TABLE @badge_issuances; DROP INDEX @idx_badge_ledger_uuid; DROP INDEX @idx_badge_ledger_purchase; @@ -193,6 +202,8 @@ DROP TABLE @badge_ledger; DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; DROP INDEX @idx_badge_invoices_purchase; +DROP INDEX @idx_badge_invoices_offer; +DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; DROP TABLE @badge_purchases; DROP TABLE @subscription_charges; @@ -200,6 +211,7 @@ DROP INDEX @idx_payments_provider_ref; DROP INDEX @idx_payments_invoice; DROP TABLE @payments; DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; DROP TABLE @badge_offers; DROP TABLE @badge_prices; |] @@ -230,6 +242,8 @@ CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; +CREATE INDEX idx_users_shown_badge ON users(shown_badge_id); + CREATE TABLE badge_code_redemptions( badge_code_redemption_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, @@ -239,7 +253,7 @@ CREATE TABLE badge_code_redemptions( master_key BLOB NOT NULL, created_at TEXT NOT NULL, UNIQUE(code) -); +) STRICT; CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); @@ -253,6 +267,7 @@ down_m20261001_user_badges = [sql| DROP INDEX idx_badge_purchases_code_redemption; DROP INDEX idx_badge_purchases_user; +DROP INDEX idx_users_shown_badge; ALTER TABLE users DROP COLUMN shown_badge_id; |] <> badgeSchemaDown "" diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index a488d7da21..059b39ec89 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -54,7 +54,8 @@ CREATE TABLE users( auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, is_user_chat_relay INTEGER NOT NULL DEFAULT 0, client_service INTEGER NOT NULL DEFAULT 0, - auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user + auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, + shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -851,6 +852,152 @@ CREATE TABLE rcv_roster_transfers( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ) STRICT; +CREATE TABLE invoices( + invoice_id TEXT NOT NULL PRIMARY KEY, + provider TEXT NOT NULL, + price INTEGER NOT NULL, + discount_amount INTEGER, + credit_amount INTEGER, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + payment_url TEXT, + payment_address TEXT, + payment_crypto_currency TEXT, + payment_crypto_amount TEXT, + expires_at TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE TABLE payments( + payment_id TEXT NOT NULL PRIMARY KEY, + invoice_id TEXT REFERENCES invoices, + provider TEXT NOT NULL, + provider_ref TEXT, + amount INTEGER, + currency TEXT, + status TEXT NOT NULL, + exception TEXT, + subscription_renews_at TEXT, + grace_until TEXT, + cancelled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + , + evidence BLOB, + receipt_code TEXT +) STRICT; +CREATE TABLE subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +) STRICT; +CREATE TABLE badge_prices( + price_id TEXT NOT NULL PRIMARY KEY, + badge_type TEXT NOT NULL, + month_price INTEGER NOT NULL, + currency TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE badge_offers( + offer_id TEXT NOT NULL PRIMARY KEY, + price_id TEXT REFERENCES badge_prices, + months INTEGER NOT NULL, + free_months INTEGER, + discount INTEGER, + status TEXT NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE badge_purchases( + badge_purchase_id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_key BLOB NOT NULL, + master_key BLOB NOT NULL, + initial_badge_type TEXT NOT NULL, + current_badge_type TEXT NOT NULL, + payment_id TEXT REFERENCES payments, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + user_id INTEGER REFERENCES users ON DELETE CASCADE, + purchase_priv_key BLOB, + alert_acked_kind TEXT, + alert_acked_episode TEXT, + alert_snooze_until TEXT, + badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions, + UNIQUE(purchase_key), + UNIQUE(payment_id) +) STRICT; +CREATE TABLE badge_invoices( + invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES invoices ON DELETE CASCADE, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + price_id TEXT NOT NULL REFERENCES badge_prices, + offer_id TEXT REFERENCES badge_offers, + months INTEGER NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE badge_subscription_changes( + change_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + from_badge_type TEXT NOT NULL, + to_badge_type TEXT NOT NULL, + from_provider_ref TEXT, + to_provider_ref TEXT, + effective TEXT NOT NULL, + status TEXT NOT NULL, + effective_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE TABLE badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT, + payment_id TEXT REFERENCES payments, + charge_id TEXT REFERENCES subscription_charges, + from_purchase_id INTEGER REFERENCES badge_purchases, + to_purchase_id INTEGER REFERENCES badge_purchases + , + entry_type_unknown INTEGER NOT NULL DEFAULT 0, + entry_type_value TEXT +) STRICT; +CREATE TABLE badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, + entry_id INTEGER REFERENCES badge_ledger, + badge_type TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + expiry TEXT NOT NULL, + credential BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; +CREATE TABLE badge_code_redemptions( + badge_code_redemption_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + code TEXT NOT NULL, + purchase_key BLOB NOT NULL, + purchase_priv_key BLOB NOT NULL, + master_key BLOB NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(code) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1385,6 +1532,37 @@ CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items( item_signed_by_group_member_id ); +CREATE INDEX idx_payments_provider_ref ON payments(provider, provider_ref); +CREATE INDEX idx_payments_invoice ON payments(invoice_id); +CREATE INDEX idx_badge_offers_price ON badge_offers(price_id); +CREATE INDEX idx_badge_invoices_purchase ON badge_invoices(badge_purchase_id); +CREATE INDEX idx_badge_invoices_offer ON badge_invoices(offer_id); +CREATE INDEX idx_badge_invoices_price ON badge_invoices(price_id); +CREATE INDEX idx_badge_subscription_changes_purchase ON badge_subscription_changes( + badge_purchase_id +); +CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); +CREATE INDEX idx_badge_ledger_purchase ON badge_ledger( + badge_purchase_id, + entry_id +); +CREATE INDEX idx_badge_ledger_payment ON badge_ledger(payment_id); +CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); +CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); +CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); +CREATE INDEX idx_badge_issuances_purchase ON badge_issuances( + badge_purchase_id, + issuance_id +); +CREATE INDEX idx_badge_issuances_entry ON badge_issuances(entry_id); +CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); +CREATE INDEX idx_users_shown_badge ON users(shown_badge_id); +CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( + user_id +); +CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( + badge_code_redemption_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW From 3ddf17dce5e8a4bc6dde8cdc24e8014f52975be1 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:06:51 +0000 Subject: [PATCH 09/16] core: redeem badge codes (#7438) --- apps/simplex-badge-service/Main.hs | 2 +- apps/simplex-badge-service/README.md | 36 ++- .../src/BadgeService/Options.hs | 33 +++ .../src/BadgeService/Service.hs | 242 +++++++++++++++-- .../src/BadgeService/Store.hs | 135 ++++++++++ .../src/Directory/Service.hs | 2 +- .../src/Directory/Store.hs | 2 +- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Responses.hs | 1 + simplex-chat.cabal | 11 +- src/Simplex/Chat.hs | 1 + src/Simplex/Chat/Badges/Code.hs | 118 ++++++++ src/Simplex/Chat/Badges/Service.hs | 83 +++++- src/Simplex/Chat/Badges/Types.hs | 56 +++- .../Util.hs => src/Simplex/Chat/Bot/Store.hs | 7 +- src/Simplex/Chat/Controller.hs | 6 +- src/Simplex/Chat/Library/Commands.hs | 145 ++++++++-- src/Simplex/Chat/PaymentService.hs | 7 + src/Simplex/Chat/PaymentService/Types.hs | 15 +- src/Simplex/Chat/Store/Badges.hs | 133 +++++++++ .../Migrations/M20261001_user_badges.hs | 2 +- .../Store/Postgres/Migrations/chat_schema.sql | 4 +- .../Migrations/M20261001_user_badges.hs | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 48 ++++ .../Store/SQLite/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/View.hs | 2 + tests/BadgeTests.hs | 153 +++++++++++ tests/Bots/BadgeServiceTests.hs | 255 ++++++++++++++++-- 28 files changed, 1408 insertions(+), 96 deletions(-) create mode 100644 apps/simplex-badge-service/src/BadgeService/Store.hs create mode 100644 src/Simplex/Chat/Badges/Code.hs rename apps/simplex-directory-service/src/Directory/Util.hs => src/Simplex/Chat/Bot/Store.hs (93%) create mode 100644 src/Simplex/Chat/Store/Badges.hs diff --git a/apps/simplex-badge-service/Main.hs b/apps/simplex-badge-service/Main.hs index b870ea8671..64a2e1d553 100644 --- a/apps/simplex-badge-service/Main.hs +++ b/apps/simplex-badge-service/Main.hs @@ -11,4 +11,4 @@ main = do opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts if runCLI then badgeServiceCLI opts - else badgeService opts terminalChatConfig + else newServiceState >>= badgeService opts terminalChatConfig diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index 2d3c2153f3..9776a8de05 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -5,11 +5,12 @@ Scaffolding for the SimpleX supporter-badge RPC service. The wire protocol is sp At this stage the service: - creates a double-ratchet contact address on first start (service RPC requires DR, see [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md)), -- listens for service requests (`CEvtServiceRequest`) on that address and responds to every command with `unsupported_version`, +- listens for service requests (`CEvtServiceRequest`) on that address, rejects a request whose `purchaseKey` is not the key the agent verified the signature against, and answers `redeemBadgeCode`, +- issues redemption codes, storing only their `SHA-256` and printing each code once, - does not accept contact requests — the address is for RPC only, -- exposes a placeholder schema migration (`sx_badge_service_test`) and its own migrations table (`sx_badge_service_migrations`). +- owns the `sx_badge_service_`-prefixed tables and its own migrations table (`sx_badge_service_migrations`). -Business logic — command dispatch, ledger writes, credential signing, provider webhooks — is left for follow-up per the plans. +Every other command still answers `unsupported_version`. Ledger writes, invoices and provider webhooks are left for follow-up per the plans; a redemption issues one credential and reports an empty statement. ## Build @@ -28,3 +29,32 @@ simplex-badge-service --help - default (no `--run-cli`): background service mode, no interactive terminal. - `--run-cli`: interactive CLI that also processes service requests (mirrors `simplex-directory-service --run-cli`). - `--no-address`: skip address creation on start-up (for operators who provision the address themselves). + +The service cannot sign credentials without an issuer key and refuses to start without one: + +- `--issuer-key-idx IDX` — the index the apps find the matching public key under (`badgePublicKeys` in `ChatConfig`). +- `--issuer-secret SECRET` — the issuer secret from `simplex-chat badge keygen`. + +The service checks the secret against the configured public key at that index and refuses to start +if they disagree: credentials signed with the wrong key cannot be verified by any client, and the +codes redeemed against them would be spent for nothing. + +## Issuing codes + +Issuing a code is an operator command sent to the running service in `--run-cli` mode, not a way +to start it — so codes are issued without a second process touching the service's database: + +``` +//issue [months] [paid|unpaid|free] +//issue supporter 12 +``` + +`months` defaults to 1 and must be between 1 and 255; the status defaults to `free` and records +whether the code was sold (`paid`), is awaiting payment (`unpaid`), or was issued by an operator +(`free`) — redemption never reads it. + +The code is printed once and only its `SHA-256` is stored, so a code that is not copied when it is +shown cannot be recovered. + +Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is +why issuing codes lives in the service rather than in core. diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs index 82d82a454d..a2cd46fd84 100644 --- a/apps/simplex-badge-service/src/BadgeService/Options.hs +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -5,16 +5,20 @@ module BadgeService.Options ( BadgeServiceOpts (..), + BadgeIssuerKey (..), getBadgeServiceOpts, badgeServiceOpts, mkChatOpts, ) where +import qualified Data.ByteString.Char8 as B import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Crypto.BBS (BBSSecretKey) +import Simplex.Messaging.Encoding.String (strDecode) data BadgeServiceOpts = BadgeServiceOpts { coreOptions :: CoreChatOpts, @@ -22,9 +26,21 @@ data BadgeServiceOpts = BadgeServiceOpts clientService :: Bool, noAddress :: Bool, runCLI :: Bool, + -- the service refuses to start without this: it cannot sign a credential + issuerKey :: Maybe BadgeIssuerKey, testing :: Bool } +-- | The issuer secret that signs credentials, and the index the apps find its public half under. +data BadgeIssuerKey = BadgeIssuerKey + { keyIdx :: Int, + secretKey :: BBSSecretKey + } + +-- BBSSecretKey derives Show, so this is written out to keep the secret out of logs and errors +instance Show BadgeIssuerKey where + show BadgeIssuerKey {keyIdx} = "issuer key " <> show keyIdx + badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts badgeServiceOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -50,6 +66,22 @@ badgeServiceOpts appDir defaultDbName = do ( long "run-cli" <> help "Run badge service as CLI" ) + issuerKeyIdx <- + optional $ + option + auto + ( long "issuer-key-idx" + <> metavar "KEY_IDX" + <> help "Index of the issuer key in the app config (required with --issuer-secret)" + ) + issuerSecret <- + optional $ + option + (eitherReader $ strDecode . B.pack) + ( long "issuer-secret" + <> metavar "ISSUER_SECRET" + <> help "Issuer secret from `simplex-chat badge keygen` (base64url)" + ) pure BadgeServiceOpts { coreOptions, @@ -57,6 +89,7 @@ badgeServiceOpts appDir defaultDbName = do clientService, noAddress, runCLI, + issuerKey = BadgeIssuerKey <$> issuerKeyIdx <*> issuerSecret, testing = False } diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 64e934872e..bfcaeda085 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -4,36 +4,60 @@ {-# LANGUAGE OverloadedStrings #-} module BadgeService.Service - ( welcomeGetOpts, + ( ServiceState (..), + newServiceState, + welcomeGetOpts, + checkIssuerKey, badgeService, badgeServiceCLI, + IssueCodeOpts (..), + issueBadgeCode, ) where import BadgeService.Options +import BadgeService.Store import BadgeService.Store.Migrate (runBadgeServiceMigrations) +import Control.Applicative (optional) import Control.Concurrent.STM import Control.Logger.Simple import Control.Monad +import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as KM +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import Data.Char (isSpace) +import Data.Functor (($>)) +import Data.Maybe (fromMaybe) +import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..)) +import Data.Time.Calendar (addDays, addGregorianMonthsClip) +import Data.Time.Calendar.WeekDate (toWeekDate) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Service +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot (initializeBotAddress') +import Simplex.Chat.Bot.Store (withDB, withDB') import Simplex.Chat.Controller import Simplex.Chat.Core (sendChatCmd, simplexChatCore) import Simplex.Chat.Options (printDbOpts) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), User (..)) -import Simplex.Messaging.Encoding.String (strEncode) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (bbsPublicKey) +import Simplex.Messaging.Encoding.String (TextEncoding, strEncode, textDecode) +import Simplex.Messaging.Version (isCompatible) import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, - serviceRequestQ :: TQueue (User, AgentInvId, J.Object) + serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object) } newServiceState :: IO ServiceState @@ -52,48 +76,123 @@ welcomeGetOpts = do putStrLn $ "Service name: " ++ T.unpack serviceName pure opts -badgeService :: BadgeServiceOpts -> ChatConfig -> IO () -badgeService opts cfg = do - env <- newServiceState +-- | Check the secret is the key trusted at its index: otherwise every code redeemed is burned. +checkIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO (Either String BadgeIssuerKey) +checkIssuerKey BadgeServiceOpts {issuerKey} ChatConfig {badgePublicKeys} = case issuerKey of + Nothing -> pure $ Left "an issuer key is required - pass both --issuer-key-idx and --issuer-secret (see `simplex-chat badge keygen`)" + Just k@BadgeIssuerKey {keyIdx, secretKey} -> + bbsPublicKey secretKey >>= \case + Left e -> pure $ Left $ "issuer secret is not a valid key: " <> e + Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of + Just pk' | pk' == pk -> Right k + Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk) + Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs" + +requireIssuerKey :: BadgeServiceOpts -> ChatConfig -> IO BadgeIssuerKey +requireIssuerKey opts cfg = + checkIssuerKey opts cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure + +badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () +badgeService opts cfg env = do + key <- requireIssuerKey opts cfg let chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, - postStartHook = Just $ badgePostStartHook opts env + postStartHook = Just $ badgePostStartHook opts env, + preCmdHook = Just badgeCmdHook } + -- the reader must not block: outputQ carries every chat event simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> - forever $ do - (_, event) <- atomically . readTBQueue $ outputQ cc - case event of - -- TODO enforce _sigKey == BadgeServiceRequest.purchaseKey (docs/protocol/badges-rpc.md). - Right (CEvtServiceRequest u reqId _sigKey reqData) -> handleServiceRequest cc u reqId reqData - _ -> pure () + raceAny_ + [ forever $ + atomically (readTBQueue $ outputQ cc) >>= \case + (_, Right (CEvtServiceRequest u reqId sigKey reqData)) -> + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) + _ -> pure (), + processQueuedRequests key env + ] badgeServiceCLI :: BadgeServiceOpts -> IO () badgeServiceCLI opts = do + key <- requireIssuerKey opts terminalChatConfig env <- newServiceState let eventHook _cc ev = do case ev of - Right (CEvtServiceRequest u reqId _sigKey reqData) -> - atomically $ writeTQueue (serviceRequestQ env) (u, reqId, reqData) + Right (CEvtServiceRequest u reqId sigKey reqData) -> + atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) _ -> pure () pure ev chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, postStartHook = Just $ badgePostStartHook opts env, + preCmdHook = Just badgeCmdHook, eventHook = Just eventHook } raceAny_ [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, - processQueuedRequests env + processQueuedRequests key env ] -processQueuedRequests :: ServiceState -> IO () -processQueuedRequests env = do +-- | issuing codes lives here rather than in core: every user's app would otherwise ship it +badgeCmdHook :: ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) +badgeCmdHook cc = \case + CustomChatCommand cmd -> Left <$> runBadgeCmd cc cmd + cmd -> pure $ Right cmd + +runBadgeCmd :: ChatController -> ByteString -> IO (Either ChatError ChatResponse) +runBadgeCmd cc cmd = case A.parseOnly issueCmdP cmd of + Left _ -> pure $ chatCmdError "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free]" + Right issueOpts -> + issueBadgeCode cc issueOpts >>= \case + Right code -> pure $ Right CRCustomChatResponse {user_ = Nothing, response = "code " <> formatBadgeCode code} + Left e -> pure $ chatCmdError $ "issuing code: " <> e + +issueCmdP :: A.Parser IssueCodeOpts +issueCmdP = + "issue " *> do + badgeType <- badgeTypeP + months_ <- optional (A.space *> A.decimal) + -- outside `optional`, which would otherwise backtrack past a bad count + months <- maybe (pure 1) checkMonths months_ + paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP) + A.skipSpace + A.endOfInput + pure IssueCodeOpts {badgeType, months, paymentStatus} + where + checkMonths n + | n >= 1 && n <= (255 :: Int) = pure n + | otherwise = fail "months must be between 1 and 255" + -- BadgeType decodes anything to BTUnknown, so a typo would issue an unusable code + badgeTypeP = + textTokenP >>= \case + BTUnknown t -> fail $ "unknown badge type " <> T.unpack t + bt -> pure bt + textTokenP :: TextEncoding a => A.Parser a + textTokenP = do + t <- A.takeWhile1 (not . isSpace) + maybe (fail "invalid value") pure $ textDecode $ safeDecodeUtf8 t + +data IssueCodeOpts = IssueCodeOpts + { badgeType :: BadgeType, + months :: Int, + paymentStatus :: BadgeCodePaymentStatus + } + +-- | The caller sees the code once; only its hash is stored, so a lost code cannot be recovered. +issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode) +issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = do + code <- randomBadgeCode $ random cc + now <- getCurrentTime + r <- withDB' "issueBadgeCode" cc $ \db -> insertBadgeCode db (badgeCodeHash code) badgeType months paymentStatus now + pure $ code <$ r + +processQueuedRequests :: BadgeIssuerKey -> ServiceState -> IO () +processQueuedRequests key env = do cc <- atomically $ readTMVar $ serviceCC env forever $ do - (u, reqId, reqData) <- atomically $ readTQueue $ serviceRequestQ env - handleServiceRequest cc u reqId reqData + (u, reqId, sigKey, reqData) <- atomically $ readTQueue $ serviceRequestQ env + handleServiceRequest key cc u reqId sigKey reqData badgePreStartHook :: BadgeServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = @@ -110,11 +209,104 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: ChatController -> User -> AgentInvId -> J.Object -> IO () -handleServiceRequest cc User {userId} reqId _reqData = do +handleServiceRequest :: BadgeIssuerKey -> ChatController -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key cc User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) - respObj = KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEUnsupportedVersion)] logInfo $ "badge service request " <> reqIdT - sendChatCmd cc (APISendServiceResponse userId reqId respObj) >>= \case + resp <- badgeServiceResponse key cc sigKey reqData + sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e + +responseObject :: BadgeServiceResponse -> J.Object +responseObject r = case J.toJSON r of + J.Object o -> o + _ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEInternal)] + +errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse +errorResponse code = BSPError {code, message = Nothing, retryAfter = Nothing} + + +-- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that +-- differs would let a client claim a purchase it cannot sign for. +badgeServiceResponse :: BadgeIssuerKey -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) of + J.Error _ -> pure $ errorResponse BSEBadRequest + J.Success BadgeServiceRequest {version, purchaseKey, request} + | not (version `isCompatible` supportedBadgeServiceVRange) -> pure $ errorResponse BSEUnsupportedVersion + | purchaseKey /= sigKey -> pure $ errorResponse BSEBadRequest + | otherwise -> case request of + BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of + Just k -> redeemCode key cc k masterKey code + Nothing -> pure $ errorResponse BSEBadRequest + -- every command but redeemBadgeCode needs a key the service already knows: that one + -- creates the purchase, so its key is unknown on a first redemption + _ -> case purchaseKey of + Nothing -> pure $ errorResponse BSEUnsupportedVersion + Just k -> + withDB' "purchaseKeyExists" cc (`purchaseKeyExists` k) >>= \case + Right True -> pure $ errorResponse BSEUnsupportedVersion + Right False -> pure $ errorResponse BSEUnknownPurchaseKey + Left _ -> pure $ errorResponse BSEInternal + +-- | Nothing is written until the credential is signed, so a signing failure leaves the code +-- unspent rather than spent with nothing behind it. +redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse +redeemCode BadgeIssuerKey {keyIdx, secretKey} cc purchaseKey masterKey codeText = case parseBadgeCode codeText of + Nothing -> pure $ errorResponse BSECodeInvalid + Just code -> + withDB' "getBadgeCode" cc (`getBadgeCode` badgeCodeHash code) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right Nothing -> pure $ errorResponse BSECodeInvalid + Right (Just IssuedCode {badgeCodeId, badgeType, redemption}) -> case redeemedResponse redemption of + Just resp -> pure resp + Nothing -> do + now <- getCurrentTime + -- TODO [badges] the code's months are ignored until the ledger credits them + let periodEnd = addMonths 1 now + badgeInfo = BadgeInfo {badgeType, badgeExpiry = Just (endOfSundayAfter periodEnd), badgeExtra = ""} + issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) >>= \case + Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal + Right credential -> do + issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 $ random cc) + let newRedemption = + NewBadgeCodeRedemption + { badgeCodeId, + issuanceId, + purchaseKey, + masterKey, + badgeType, + credential, + periodStart = now, + periodEnd, + expiry = endOfSundayAfter periodEnd + } + -- re-read: a concurrent redemption may have landed while this one was signing + r <- withDB "writeCodeRedemption" cc $ \db -> + liftIO (getBadgeCode db $ badgeCodeHash code) >>= \case + Just IssuedCode {redemption = current} | Just resp <- redeemedResponse current -> pure resp + _ -> liftIO $ credentialResponse credential <$ writeCodeRedemption db newRedemption now + pure $ either (const $ errorResponse BSEInternal) id r + where + -- one definition, used before signing and again inside the write transaction + redeemedResponse = \case + CodeUnredeemed -> Nothing + CodeRedeemedUnreadable -> Just $ errorResponse BSEInternal + CodeRedeemed RedeemedCode {purchaseKey = k, credential} + | k == purchaseKey -> Just $ credentialResponse credential + | otherwise -> Just $ errorResponse BSECodeUsed + +-- TODO [badges] the statement is empty until the ledger is written +credentialResponse :: BadgeCredential -> BadgeServiceResponse +credentialResponse credential = + BSPBadgeCredential {credential = Just credential, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}} + +addMonths :: Integer -> UTCTime -> UTCTime +addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t + +-- Every badge in a week expires together, revealing nothing about when it was bought. +-- The end of a Sunday is the next Monday at 00:00, so this returns a Monday and 8 is right. +endOfSundayAfter :: UTCTime -> UTCTime +endOfSundayAfter (UTCTime d _) = + let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday + in UTCTime (addDays (toInteger (8 - dayOfWeek)) d) 0 diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs new file mode 100644 index 0000000000..3df619b331 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -0,0 +1,135 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module BadgeService.Store + ( IssuedCode (..), + CodeRedemption (..), + RedeemedCode (..), + NewBadgeCodeRedemption (..), + getBadgeCode, + purchaseKeyExists, + writeCodeRedemption, + insertBadgeCode, + ) +where + +import qualified Data.Aeson as J +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) +import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data IssuedCode = IssuedCode + { badgeCodeId :: Int64, + badgeType :: BadgeType, + redemption :: CodeRedemption + } + +-- A code that has a purchase is spent, even if its credential cannot be read. Treating that as +-- an unredeemed code would issue a second credential for it. +data CodeRedemption + = CodeUnredeemed + | CodeRedeemed RedeemedCode + | CodeRedeemedUnreadable + +data RedeemedCode = RedeemedCode + { purchaseKey :: C.PublicKeyEd25519, + credential :: BadgeCredential + } + +-- Everything writeCodeRedemption inserts, so that the purchase, its issuance and the spent code +-- are written in one transaction. If the code were marked redeemed and one of the other writes +-- failed, it would be spent with no credential behind it, and nothing can reissue it. +data NewBadgeCodeRedemption = NewBadgeCodeRedemption + { badgeCodeId :: Int64, + issuanceId :: Text, + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType, + credential :: BadgeCredential, + periodStart :: UTCTime, + periodEnd :: UTCTime, + expiry :: UTCTime + } + +getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode) +getBadgeCode db codeHash = + maybeFirstRow toCode $ + DB.query + db + [sql| + SELECT c.badge_code_id, c.badge_type, p.purchase_key, i.credential + FROM sx_badge_service_badge_codes c + LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id + LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id + WHERE c.code_hash = ? + ORDER BY i.created_at DESC + LIMIT 1 + |] + (Only (Binary codeHash)) + where + toCode (badgeCodeId, badgeType, purchaseKey_, credential_) = + IssuedCode {badgeCodeId, badgeType, redemption = codeRedemption purchaseKey_ credential_} + codeRedemption purchaseKey_ credential_ = case purchaseKey_ of + Nothing -> CodeUnredeemed + Just purchaseKey -> case decodeCredential =<< credential_ of + Just credential -> CodeRedeemed RedeemedCode {purchaseKey, credential} + Nothing -> CodeRedeemedUnreadable + decodeCredential (Binary bs) = J.decodeStrict' bs + +purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool +purchaseKeyExists db key = + maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $ + DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key) + +-- one transaction: the caller has already signed, so no code is left spent without a credential +writeCodeRedemption :: DB.Connection -> NewBadgeCodeRedemption -> UTCTime -> IO () +writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType, credential, periodStart, periodEnd, expiry} now = do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, badge_code_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) + purchaseId <- insertedRowId db + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_issuances + (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (issuanceId, purchaseId, badgeType, periodStart, periodEnd, expiry, Binary (LB.toStrict $ J.encode credential), now) + DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId) + +insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO () +insertBadgeCode db codeHash badgeType months paymentStatus now = + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) + VALUES (?,?,?,?,?) + |] + (Binary codeHash, badgeType, months, paymentStatus, now) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index fd0c42b5e1..ef76776ee2 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -47,9 +47,9 @@ import Directory.Options import Directory.Search import Directory.Store import Directory.Store.Migrate -import Directory.Util import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Bot.Store import Simplex.Chat.Controller import Simplex.Chat.Core import Simplex.Chat.Library.Internal (setGroupLinkData) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index ea7f7f5ee0..5594b02bae 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -68,7 +68,7 @@ import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime (..), getCurrentTime) import Directory.Search -import Directory.Util +import Simplex.Chat.Bot.Store import Simplex.Chat.Controller import Simplex.Chat.Names (claimDomain) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index d126ff1844..052afcb6e6 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -398,6 +398,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIRedeemBadgeCode", "APIRegisterToken", "APIRejectCall", "APIReorderChatTags", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 76f1ddb76b..151a238054 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -129,6 +129,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeRedeemed", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 58978cadbf..e4e0a3577c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -40,6 +40,7 @@ library Simplex.Chat.AppSettings Simplex.Chat.Badges Simplex.Chat.Badges.CLI + Simplex.Chat.Badges.Code Simplex.Chat.Badges.Service Simplex.Chat.Badges.Types Simplex.Chat.Names @@ -79,6 +80,7 @@ library Simplex.Chat.Stats Simplex.Chat.Store Simplex.Chat.Store.AppSettings + Simplex.Chat.Store.Badges Simplex.Chat.Store.Connections Simplex.Chat.Store.ContactRequest Simplex.Chat.Store.Delivery @@ -103,6 +105,7 @@ library exposed-modules: Simplex.Chat.Bot Simplex.Chat.Bot.KnownContacts + Simplex.Chat.Bot.Store Simplex.Chat.Core Simplex.Chat.Help Simplex.Chat.Terminal @@ -418,18 +421,23 @@ executable simplex-badge-service other-modules: BadgeService.Options BadgeService.Service + BadgeService.Store BadgeService.Store.Migrate Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: aeson ==2.2.* + , attoparsec ==0.14.* , base >=4.7 && <5 + , crypton ==0.34.* , directory ==1.3.* + , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 , simple-logger ==0.1.* , simplex-chat , simplexmq >=6.3 , stm ==2.5.* + , time ==1.12.* default-language: Haskell2010 if flag(client_postgres) other-modules: @@ -583,7 +591,6 @@ executable simplex-directory-service Directory.Service Directory.Store Directory.Store.Migrate - Directory.Util Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: @@ -679,6 +686,7 @@ test-suite simplex-chat-test API.TypeInfo BadgeService.Options BadgeService.Service + BadgeService.Store BadgeService.Store.Migrate Bots.BadgeServiceTests Broadcast.Bot @@ -692,7 +700,6 @@ test-suite simplex-chat-test Directory.Service Directory.Store Directory.Store.Migrate - Directory.Util Paths_simplex_chat if flag(client_postgres) other-modules: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cd44b69df6..bfd279231b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -77,6 +77,7 @@ defaultChatConfig = (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") ], + badgeServiceAddress = Nothing, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options diff --git a/src/Simplex/Chat/Badges/Code.hs b/src/Simplex/Chat/Badges/Code.hs new file mode 100644 index 0000000000..cff8812b80 --- /dev/null +++ b/src/Simplex/Chat/Badges/Code.hs @@ -0,0 +1,118 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Badge redemption codes, shared by the client, the badge service and the checkout site. +-- +-- A code is @SXB-@ and 20 Crockford base32 characters in four groups of five: +-- 19 payload characters and a final check character. +-- +-- Reading folds the characters the alphabet omits so that a code copied by hand still +-- verifies: it is case-insensitive and maps @I@ and @L@ to @1@ and @O@ to @0@. +-- +-- The check character is Luhn mod N with N = 32 over the payload values, which keeps it +-- inside the same 32-character alphabet. It detects every single-character substitution +-- and every transposition of adjacent characters except '0' next to 'Z' - the values 0 and +-- N-1, which is Luhn's one blind spot at any base. +-- +-- 'BadgeCode' is only constructed by 'parseBadgeCode' and 'randomBadgeCode', so a code +-- whose check character fails cannot be hashed, looked up or sent. +module Simplex.Chat.Badges.Code + ( BadgeCode, + parseBadgeCode, + randomBadgeCode, + badgeCodeText, + badgeCodeHash, + formatBadgeCode, + ) +where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Char (isAlphaNum, toUpper) +import Data.List (elemIndex) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import qualified Simplex.Messaging.Crypto as C + +-- | A code that has passed its check character, in canonical form: 'codePrefix' followed by +-- 20 upper-case alphabet characters, without separators. +newtype BadgeCode = BadgeCode Text + deriving (Eq, Show) + +-- Crockford base32: the digits and the upper-case letters except I, L, O and U. +alphabet :: String +alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +base :: Int +base = 32 + +codeLength :: Int +codeLength = 20 + +groupLength :: Int +groupLength = 5 + +codePrefix :: Text +codePrefix = "SXB" + +-- | The Crockford value of a character, folding the omitted characters onto the digits they +-- are mistaken for. +charValue :: Char -> Maybe Int +charValue c = case toUpper c of + 'I' -> Just 1 + 'L' -> Just 1 + 'O' -> Just 0 + u -> elemIndex u alphabet + +valueChar :: Int -> Char +valueChar v = alphabet !! v + +-- | Luhn mod N (N = 32): the value that makes the whole code sum to zero modulo the base. +checkValue :: [Int] -> Int +checkValue payload = (base - total `mod` base) `mod` base + where + -- doubling every second value from the right, as the check character sits to the right of the payload + total = fst $ foldr step (0, 2) payload + step v (sum', factor) = + let addend = factor * v + in (sum' + addend `div` base + addend `mod` base, if factor == 2 then 1 else 2) + +-- | Read a code as typed: any case, separators optional, ambiguous characters folded. +-- 'Nothing' for anything not well-formed, a failed check character included. +parseBadgeCode :: Text -> Maybe BadgeCode +parseBadgeCode t = do + body <- T.stripPrefix codePrefix $ T.toUpper $ T.filter isAlphaNum t + vs <- mapM charValue $ T.unpack body + let (payload, checkChar) = splitAt (codeLength - 1) vs + if T.length body == codeLength && checkChar == [checkValue payload] + -- rebuilt from the values, not from body: that is what folds I/L/O into the canonical form + then Just $ BadgeCode $ codePrefix <> T.pack (map valueChar vs) + else Nothing + +-- | A new code from the CSPRNG. 256 is a multiple of the base, so a byte reduces without bias. +randomBadgeCode :: TVar ChaChaDRG -> IO BadgeCode +randomBadgeCode drg = do + bs <- atomically $ C.randomBytes (codeLength - 1) drg + let payload = map ((`mod` base) . fromEnum) $ B.unpack bs + vs = payload <> [checkValue payload] + pure $ BadgeCode $ codePrefix <> T.pack (map valueChar vs) + +-- | The canonical form: the only representation of a code that is hashed or sent. +badgeCodeText :: BadgeCode -> Text +badgeCodeText (BadgeCode t) = t + +-- | The only thing about a code stored service-side: SHA-256 over the ASCII bytes of the +-- canonical form, prefix included. +badgeCodeHash :: BadgeCode -> ByteString +badgeCodeHash = C.sha256Hash . encodeUtf8 . badgeCodeText + +-- | The code as it is shown and printed, in four groups of five. +formatBadgeCode :: BadgeCode -> Text +formatBadgeCode (BadgeCode t) = T.intercalate "-" $ codePrefix : groups (T.drop (T.length codePrefix) t) + where + groups s + | T.null s = [] + | otherwise = let (g, rest) = T.splitAt groupLength s in g : groups rest diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index ff06fb7d94..7de198fec9 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -3,13 +3,18 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PatternSynonyms #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Badges.Service ( BadgeServiceRequest (..), BadgeServiceCommand (..), BadgeServiceVersion, VersionBadgeService, + VersionRangeBadgeService, pattern VersionBadgeService, + initialBadgeServiceVersion, + currentBadgeServiceVersion, + supportedBadgeServiceVRange, BadgeUpgrade (..), BadgeServiceResponse (..), BadgeServiceErrorCode (..), @@ -24,9 +29,12 @@ module Simplex.Chat.Badges.Service StatementDebitType (..), ) where -import Data.Aeson (FromJSON (..), ToJSON (..)) +import Control.Applicative ((<|>)) +import Data.Aeson (FromJSON (..), ToJSON (..), (.:)) import qualified Data.Aeson as J -import Data.Int (Int64) +import qualified Data.Aeson.Encoding as JE +import qualified Data.Aeson.TH as JQ +import qualified Data.Aeson.Types as JT import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8, Word16, Word32) @@ -35,7 +43,8 @@ import Simplex.Chat.Badges.Types import Simplex.Chat.PaymentService import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Version (VersionScope) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import Simplex.Messaging.Version (VersionRange, VersionScope, mkVersionRange) import Simplex.Messaging.Version.Internal (Version (..)) data BadgeServiceVersion @@ -47,6 +56,18 @@ type VersionBadgeService = Version BadgeServiceVersion pattern VersionBadgeService :: Word16 -> VersionBadgeService pattern VersionBadgeService v = Version v +type VersionRangeBadgeService = VersionRange BadgeServiceVersion + +initialBadgeServiceVersion :: VersionBadgeService +initialBadgeServiceVersion = VersionBadgeService 1 + +currentBadgeServiceVersion :: VersionBadgeService +currentBadgeServiceVersion = VersionBadgeService 1 + +-- the service is deployed ahead of app releases, so it answers within the client's version +supportedBadgeServiceVRange :: VersionRangeBadgeService +supportedBadgeServiceVRange = mkVersionRange initialBadgeServiceVersion currentBadgeServiceVersion + data BadgeServiceRequest = BadgeServiceRequest { version :: VersionBadgeService, purchaseKey :: Maybe C.PublicKeyEd25519, -- optional for BSCGetBadgeCatalog, required for other commands @@ -164,7 +185,7 @@ data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {de data StatementCreditType = SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments - | SCCharge {chargeId :: Int64} + | SCCharge {chargeId :: Text} | SCSupport | SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519} | SCOpening @@ -248,3 +269,57 @@ instance ToJSON BadgeServiceErrorCode where instance FromJSON BadgeServiceErrorCode where parseJSON = textParseJSON "BadgeServiceErrorCode" + +$(pure []) + +instance FromJSON StatementCreditType where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + <|> SCUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad StatementCreditType, " (JT.typeMismatch "Object" invalid) + +instance ToJSON StatementCreditType where + toJSON = \case + SCUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + toEncoding = \case + SCUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SC") ''StatementCreditType) v + +instance FromJSON StatementDebitType where + parseJSON v@(J.Object j) = + $(JQ.mkParseJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + <|> SDUnknown <$> j .: "type" <*> pure j + parseJSON invalid = + JT.prependFailure "bad StatementDebitType, " (JT.typeMismatch "Object" invalid) + +instance ToJSON StatementDebitType where + toJSON = \case + SDUnknown _ j -> J.Object j + v -> $(JQ.mkToJSON (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + toEncoding = \case + SDUnknown _ j -> JE.value $ J.Object j + v -> $(JQ.mkToEncoding (taggedObjectJSON $ dropPrefix "SD") ''StatementDebitType) v + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SE") ''StatementEntryType) + +$(JQ.deriveJSON defaultJSON ''StatementEntry) + +$(JQ.deriveJSON defaultJSON ''BadgeStatement) + +$(JQ.deriveJSON defaultJSON ''BadgeBalance) + +$(JQ.deriveJSON defaultJSON ''BadgeUpgrade) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSC") ''BadgeServiceCommand) + +$(JQ.deriveJSON defaultJSON ''BadgeServiceRequest) + +$(JQ.deriveJSON defaultJSON ''BadgePrice) + +$(JQ.deriveJSON defaultJSON ''BadgeOffer) + +$(JQ.deriveJSON defaultJSON ''BadgeCatalog) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSP") ''BadgeServiceResponse) diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index c5289504f0..31516dbdfe 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -1,6 +1,10 @@ +{-# LANGUAGE CPP #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Badges.Types ( BadgePriceId (..), @@ -22,7 +26,9 @@ module Simplex.Chat.Badges.Types UserBadgeState (..), ) where +import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) @@ -30,15 +36,25 @@ import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment) import Simplex.Messaging.Agent.Protocol (UserId) +import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple.FromField (FromField (..)) +import Database.PostgreSQL.Simple.ToField (ToField (..)) +#else +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +#endif -- confirmed newtype BadgePriceId = BadgePriceId Text - deriving newtype (Eq, Show) + deriving newtype (Eq, Show, ToJSON, FromJSON) -- confirmed newtype BadgeOfferId = BadgeOfferId Text - deriving newtype (Eq, Show) + deriving newtype (Eq, Show, ToJSON, FromJSON) -- unconfirmed draft data BadgePlan = BPOneTime | BPMonthly | BPAnnual @@ -172,3 +188,39 @@ data UserBadgeState = UserBadgeState willRenew :: Bool, alert :: Maybe BadgeAlert } + +instance TextEncoding BadgePurchaseStatus where + textEncode = \case + PSAcquiring -> "acquiring" + PSIssued -> "issued" + PSSuperseded -> "superseded" + PSFailed -> "failed" + textDecode = \case + "acquiring" -> Just PSAcquiring + "issued" -> Just PSIssued + "superseded" -> Just PSSuperseded + "failed" -> Just PSFailed + _ -> Nothing + +instance FromField BadgePurchaseStatus where fromField = fromTextField_ textDecode + +instance ToField BadgePurchaseStatus where toField = toField . textEncode + +instance TextEncoding BadgeCodePaymentStatus where + textEncode = \case + CPSPaid -> "paid" + CPSUnpaid -> "unpaid" + CPSFree -> "free" + textDecode = \case + "paid" -> Just CPSPaid + "unpaid" -> Just CPSUnpaid + "free" -> Just CPSFree + _ -> Nothing + +instance FromField BadgeCodePaymentStatus where fromField = fromTextField_ textDecode + +instance ToField BadgeCodePaymentStatus where toField = toField . textEncode + +$(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/src/Simplex/Chat/Bot/Store.hs similarity index 93% rename from apps/simplex-directory-service/src/Directory/Util.hs rename to src/Simplex/Chat/Bot/Store.hs index 52d376a945..1f5a2924d5 100644 --- a/apps/simplex-directory-service/src/Directory/Util.hs +++ b/src/Simplex/Chat/Bot/Store.hs @@ -3,7 +3,12 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module Directory.Util where +module Simplex.Chat.Bot.Store + ( storeCxt, + withDB, + withDB', + ) +where import Control.Logger.Simple import Control.Monad.Except diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 54dfb58fc3..bc46533b29 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -83,7 +83,7 @@ import Simplex.Messaging.Agent.Store.DB (SQLError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C -import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Chat.Badges (BadgeCredential, LocalBadge) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -143,6 +143,8 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation badgePublicKeys :: Map Int BBSPublicKey, + -- Nothing until the badge service is deployed + badgeServiceAddress :: Maybe (ConnectTarget 'CMContact), confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -638,6 +640,7 @@ data ChatCommand | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) + | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) @@ -842,6 +845,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} + | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8629c463af..35abcd6d1d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -56,7 +56,9 @@ import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber -import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) +import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -77,6 +79,7 @@ import Simplex.Chat.Library.Internal import Simplex.Chat.Stats import Simplex.Chat.Store import Simplex.Chat.Store.AppSettings +import Simplex.Chat.Store.Badges import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Delivery @@ -1464,26 +1467,8 @@ processChatCommand cxt nm = \case liftIO $ deleteContactRequest db user connReqId pure ct_ pure $ CRContactRequestRejected user cReq ct_ - APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> do - cReq <- resolveServiceTarget user sendTarget - respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout (C.unStored <$> signKey) (LB.toStrict $ J.encode request) - resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData - pure $ CRServiceResponse user resp - where - resolveServiceTarget user = \case - CTFullContact cReq -> pure cReq - CTShortContact (CTLink sLnk) -> resolveShortLink sLnk - CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of - NTContact -> resolveDomain nameDomain - _ -> throwCmdError "service request target must be a contact" - CTDomain d -> resolveDomain d - where - resolveDomain d = do - nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d - case firstNameLink CCTContact (nrSimplexContact nr) of - Just sLnk -> resolveShortLink sLnk - Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink - resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk + APISendServiceRequest userId sendTarget requestTimeout signKey request -> withUserId userId $ \user -> + CRServiceResponse user <$> sendServiceRequestTo nm user sendTarget requestTimeout (C.unStored <$> signKey) request APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do let AgentInvId invId = requestId connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) @@ -3552,6 +3537,7 @@ processChatCommand cxt nm = \case pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user + APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -5133,20 +5119,31 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference +-- | Verify an own credential against the configured issuer keys. +-- Nothing means its key index is not among them, so this version cannot verify it at all. +verifyOwnBadge :: BadgeCredential -> CM (Maybe Bool) +verifyOwnBadge cred@(BadgeCredential keyIdx _ _ _) = do + keys <- asks $ badgePublicKeys . config + forM (M.lookup keyIdx keys) $ \key -> liftIO $ verifyCredential key cred + -- attach an issued badge credential to the user's own profile and present it to all current contacts. -- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). addUserBadge :: User -> BadgeCredential -> CM () -addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do - keys <- asks $ badgePublicKeys . config - key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys - verified <- liftIO $ verifyCredential key cred - unless verified $ throwCmdError "badge credential does not verify against configured key" - now <- liftIO getCurrentTime - user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) +addUserBadge user cred@(BadgeCredential _ _ _ info) = + verifyOwnBadge cred >>= \case + Nothing -> throwCmdError "unknown badge key index" + Just False -> throwCmdError "badge credential does not verify against configured key" + Just True -> do + now <- liftIO getCurrentTime + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + presentUserBadgeToContacts user' + +presentUserBadgeToContacts :: User -> CM () +presentUserBadgeToContacts user' = do asks currentUser >>= atomically . (`writeTVar` Just user') cxt <- asks $ mkStoreCxt . config contacts <- withFastStore' $ \db -> getUserContacts db cxt user' - withChatLock "addUserBadge" $ forM_ contacts $ \ct -> + withChatLock "presentUserBadge" $ forM_ contacts $ \ct -> case contactSendConn_ ct of Right conn | not (connIncognito conn) -> do @@ -5155,6 +5152,95 @@ addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView _ -> pure () +-- | The check character is verified before anything leaves the device, and the signing keys are +-- stashed before the request is sent, so a retry reaches the service as the same signer. +-- A terminal answer drops the stash; a timeout keeps it. +redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse +redeemBadgeCode nm user codeText = do + code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText + sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure + g <- asks random + now <- liftIO getCurrentTime + let codeSent = badgeCodeText code + redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- + withStore' $ \db -> + getBadgeCodeRedemption db user codeSent + >>= maybe (createBadgeCodeRedemption db g user codeSent now) pure + let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} + respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req + case J.fromJSON (J.Object respData) of + J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData + J.Success BSPError {code = errCode} -> do + when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) + throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode) + J.Success BSPBadgeCredential {credential = Just cred} -> storeRedeemedBadge user redemption cred + J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData + where + -- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on + respJSON = LB.unpack . J.encode + -- the code will never work, so the keys stashed for it are dead; a timeout keeps them + terminalCodeError = \case + BSECodeInvalid -> True + BSECodeUsed -> True + BSECodeExpired -> True + _ -> False + +-- | An unknown code is reported, since the service is deployed ahead of clients, but its text is +-- the service's - so it is bounded and stripped before reaching a terminal that acts on controls. +badgeServiceErrorText :: BadgeServiceErrorCode -> Text +badgeServiceErrorText = \case + BSEUnknown t -> case T.filter errorCodeChar (T.take 32 t) of + "" -> "unknown" + t' -> t' + code -> textEncode code + where + errorCodeChar c = isAsciiLower c || isDigit c || c == '_' + +-- | Verify the credential before writing anything; the purchase, its issuance and the profile's +-- badge go in one transaction, and contacts are told after it commits. +storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> CM ChatResponse +storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeExpiry}) = + verifyOwnBadge cred >>= \case + Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index" + Just False -> throwCmdError "redeemed badge credential does not verify against configured key" + -- verifyCredential checks the signature against the key inside the credential, not the one we + -- sent - so a credential over any other master key also verifies + Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key" + Just True -> do + -- badge_issuances requires a period; a credential without an expiry has none to record + expiry <- maybe (throwCmdError "redeemed badge credential has no expiry") pure badgeExpiry + g <- asks random + now <- liftIO getCurrentTime + let badge = OwnBadge cred (mkBadgeStatus now (Just True) info) + -- TODO [badges] copy the statement's ledger entries, and retire a previously held badge + (user', newBadge) <- withStore' $ \db -> do + newBadge <- createCodeBadgePurchase db g user redemption cred expiry now + -- a replay must not put a superseded badge back, or tell every contact again + user' <- if newBadge then setUserBadge db user (Just badge) else pure user + pure (user', newBadge) + when newBadge $ presentUserBadgeToContacts user' + pure $ CRBadgeRedeemed user' badge newBadge + +sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object +sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do + cReq <- resolveServiceTarget sendTarget + respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout signKey (LB.toStrict $ J.encode request) + either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData + where + resolveServiceTarget = \case + CTFullContact cReq -> pure cReq + CTShortContact (CTLink sLnk) -> resolveShortLink sLnk + CTShortContact (CTName SimplexNameInfo {nameType, nameDomain}) -> case nameType of + NTContact -> resolveDomain nameDomain + _ -> throwCmdError "service request target must be a contact" + CTDomain d -> resolveDomain d + resolveDomain d = do + nr <- withAgent $ \a -> resolveSimplexName a nm (aUserId user) d + case firstNameLink CCTContact (nrSimplexContact nr) of + Just sLnk -> resolveShortLink sLnk + Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink + resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5539,6 +5625,7 @@ chatCommandP = "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), + "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), diff --git a/src/Simplex/Chat/PaymentService.hs b/src/Simplex/Chat/PaymentService.hs index 4cbfc0ef11..38f94b2118 100644 --- a/src/Simplex/Chat/PaymentService.hs +++ b/src/Simplex/Chat/PaymentService.hs @@ -1,4 +1,5 @@ {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService ( ServiceInvoice (..), @@ -6,9 +7,11 @@ module Simplex.Chat.PaymentService module Simplex.Chat.PaymentService.Types, ) where +import qualified Data.Aeson.TH as JQ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.PaymentService.Types +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) data ServiceInvoice = ServiceInvoice { invoiceId :: InvoiceId, @@ -28,3 +31,7 @@ data ServicePayment | SPInvoice {invoiceId :: InvoiceId} | SPReceipt {receipt :: Text} -- transfer of unissued months deriving (Show) + +$(JQ.deriveJSON defaultJSON ''ServiceInvoice) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SP") ''ServicePayment) diff --git a/src/Simplex/Chat/PaymentService/Types.hs b/src/Simplex/Chat/PaymentService/Types.hs index 910a5b973f..5df3aea92a 100644 --- a/src/Simplex/Chat/PaymentService/Types.hs +++ b/src/Simplex/Chat/PaymentService/Types.hs @@ -1,6 +1,7 @@ {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.PaymentService.Types ( CurrencyAmount (..), @@ -19,18 +20,22 @@ module Simplex.Chat.PaymentService.Types PaymentStatus (..), ) where +import Data.Aeson (FromJSON, ToJSON) +import qualified Data.Aeson.TH as JQ import Data.ByteString.Char8 (ByteString) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word32) +import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) -- USD etc. are in minor units, following Stripe etc. convention newtype CurrencyAmount = CurrencyAmount Word32 deriving (Eq, Show) + deriving newtype (ToJSON, FromJSON) -- confirmed newtype InvoiceId = InvoiceId Text - deriving newtype (Eq, Show) + deriving newtype (Eq, Show, ToJSON, FromJSON) -- confirmed newtype PaymentId = PaymentId Text @@ -132,3 +137,11 @@ data PaymentTerm -- to review data PaymentStatus = PSPending | PSSettled | PSFailed {exception :: Text} deriving (Show) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CP") ''CardProvider) + +$(JQ.deriveJSON (enumJSON $ dropPrefix "CC") ''CryptoCurrency) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPM") ''ServicePaymentMethod) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SPD") ''ServicePaymentDestination) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs new file mode 100644 index 0000000000..fb443077bd --- /dev/null +++ b/src/Simplex/Chat/Store/Badges.hs @@ -0,0 +1,133 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Badges + ( BadgeCodeRedemption (..), + getBadgeCodeRedemption, + createBadgeCodeRedemption, + deleteBadgeCodeRedemption, + createCodeBadgePurchase, + ) +where + +import Control.Concurrent.STM (TVar, atomically) +import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson as J +import qualified Data.ByteString.Lazy.Char8 as LB +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Badges +import Simplex.Chat.Badges.Types (BadgePurchaseStatus (..)) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Util (maybeFirstRow, safeDecodeUtf8) + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +-- | The keys one redemption attempt is signed with, stashed before the request is sent so that a +-- retry reaches the service as the same signer and is answered with the credential already issued. +data BadgeCodeRedemption = BadgeCodeRedemption + { redemptionId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey + } + +getBadgeCodeRedemption :: DB.Connection -> User -> Text -> IO (Maybe BadgeCodeRedemption) +getBadgeCodeRedemption db User {userId} code = + maybeFirstRow toRedemption $ + DB.query + db + [sql| + SELECT badge_code_redemption_id, purchase_key, purchase_priv_key, master_key + FROM badge_code_redemptions + WHERE user_id = ? AND code = ? + |] + (userId, code) + where + toRedemption (redemptionId, purchaseKey, purchasePrivKey, Binary mk) = + BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} + +createBadgeCodeRedemption :: DB.Connection -> TVar ChaChaDRG -> User -> Text -> UTCTime -> IO BadgeCodeRedemption +createBadgeCodeRedemption db g User {userId} code now = do + (purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g + masterKey@(BadgeMasterKey mk) <- generateMasterKey g + DB.execute + db + [sql| + INSERT INTO badge_code_redemptions (user_id, code, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + |] + (userId, code, purchaseKey, purchasePrivKey, Binary mk, now) + redemptionId <- insertedRowId db + pure BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey} + +-- | Drop a stashed attempt whose code the service refused for good, unless a purchase already +-- came from it - badge_purchases references this row. +deleteBadgeCodeRedemption :: DB.Connection -> Int64 -> IO () +deleteBadgeCodeRedemption db redemptionId = + DB.execute + db + [sql| + DELETE FROM badge_code_redemptions + WHERE badge_code_redemption_id = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + |] + (redemptionId, redemptionId) + +-- | The purchase a redeemed code created, its issuance, and the profile's pointer to it. +-- False when the code was already redeemed here: the service replays the credential it issued, +-- and that must add no purchase and leave the shown badge alone. The expiry is passed in because +-- badge_issuances requires one and the caller has already resolved it. +createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool +createCodeBadgePurchase db g User {userId} redemption credential expiry now = + getCodeBadgePurchase db redemption >>= \case + Just _ -> pure False + Nothing -> do + purchaseId <- insertPurchase + DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) + pure True + where + BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption + BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential + insertPurchase = do + DB.execute + db + [sql| + INSERT INTO badge_purchases + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + |] + (userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now) + purchaseId <- insertedRowId db + issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g) + -- TODO [badges] the credential's expiry stands in for the period end, which is up to a + -- week later, until the statement carries the real period + DB.execute + db + [sql| + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (issuanceId, purchaseId, badgeType, now, expiry, expiry, Binary (LB.toStrict $ J.encode credential), now) + pure purchaseId + +getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64) +getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} = + maybeFirstRow fromOnly $ + DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs index 0aa9153b2c..4606e7c307 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs @@ -251,7 +251,7 @@ CREATE TABLE badge_code_redemptions( purchase_priv_key BYTEA NOT NULL, master_key BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, - UNIQUE(code) + UNIQUE(user_id, code) ); CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index fbc663f45d..6d4ec6bc0a 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1763,12 +1763,12 @@ ALTER TABLE test_chat_schema.xftp_file_descriptions ALTER COLUMN file_descr_id A ALTER TABLE ONLY test_chat_schema.badge_code_redemptions - ADD CONSTRAINT badge_code_redemptions_code_key UNIQUE (code); + ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id); ALTER TABLE ONLY test_chat_schema.badge_code_redemptions - ADD CONSTRAINT badge_code_redemptions_pkey PRIMARY KEY (badge_code_redemption_id); + ADD CONSTRAINT badge_code_redemptions_user_id_code_key UNIQUE (user_id, code); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs index af91e24af0..ae1e138888 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs @@ -252,7 +252,7 @@ CREATE TABLE badge_code_redemptions( purchase_priv_key BLOB NOT NULL, master_key BLOB NOT NULL, created_at TEXT NOT NULL, - UNIQUE(code) + UNIQUE(user_id, code) ) STRICT; CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(user_id); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 5105f7b610..626bc3fedc 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1203,6 +1203,19 @@ Query: Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) +Query: + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?) + +Plan: + +Query: + INSERT INTO badge_purchases + (user_id, purchase_key, purchase_priv_key, master_key, initial_badge_type, current_badge_type, status, badge_code_redemption_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO chat_item_reactions (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) @@ -3463,6 +3476,14 @@ Query: Plan: SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) +Query: + SELECT badge_code_redemption_id, purchase_key, purchase_priv_key, master_key + FROM badge_code_redemptions + WHERE user_id = ? AND code = ? + +Plan: +SEARCH badge_code_redemptions USING INDEX sqlite_autoindex_badge_code_redemptions_1 (user_id=? AND code=?) + Query: SELECT c.agent_conn_id FROM connections c @@ -4297,6 +4318,17 @@ SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?) +Query: + DELETE FROM badge_code_redemptions + WHERE badge_code_redemption_id = ? + AND NOT EXISTS (SELECT 1 FROM badge_purchases WHERE badge_code_redemption_id = ?) + +Plan: +SEARCH badge_code_redemptions USING INTEGER PRIMARY KEY (rowid=?) +SCALAR SUBQUERY 1 +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) + Query: DELETE FROM chat_items WHERE group_scope_group_member_id = ? @@ -4782,6 +4814,12 @@ LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) SEARCH groups USING COVERING INDEX idx_groups_group_profile_id (group_profile_id=?) +Query: + INSERT INTO badge_code_redemptions (user_id, code, purchase_key, purchase_priv_key, master_key, created_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO calls (contact_id, shared_call_id, call_uuid, chat_item_id, call_state, call_ts, user_id, created_at, updated_at) @@ -6944,6 +6982,8 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?) +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?) SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) SEARCH chat_tags USING COVERING INDEX idx_chat_tags_user_id (user_id=?) SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) @@ -7177,6 +7217,10 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ? +Plan: +SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) + Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ? Plan: SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name @@ -7982,6 +8026,10 @@ Query: UPDATE users SET send_rcpts_small_groups = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE users SET shown_badge_id = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE users SET ui_themes = ?, updated_at = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 059b39ec89..6a0429ab72 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -996,7 +996,7 @@ CREATE TABLE badge_code_redemptions( purchase_priv_key BLOB NOT NULL, master_key BLOB NOT NULL, created_at TEXT NOT NULL, - UNIQUE(code) + UNIQUE(user_id, code) ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6731b62fd6..81341cb913 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -188,6 +188,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] + -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be + CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 90e3e9ae7a..b4190ca6a3 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -1,19 +1,27 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DisambiguateRecordFields #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module BadgeTests (badgeTests) where +import Control.Concurrent.STM (atomically) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import qualified Data.Text as T import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Data.Aeson as J import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges +import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Service import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec badgeTests :: Spec @@ -27,6 +35,16 @@ badgeTests = do it "should treat lifetime badges as always active" testLifetimeBadge it "should accept unknown badge types" testUnknownBadgeType it "credential serializes to a paste-able token and back" testCredentialSerialization + describe "redemption codes" $ do + it "a generated code reads back" testCodeRoundTrip + it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation + it "rejects a code whose check character does not match" testCodeCheckCharacter + it "hashes the canonical form, whatever was typed" testCodeHash + describe "service protocol JSON" $ do + it "redeemBadgeCode request matches the schema" testRedeemRequestJSON + it "badgeCredential response matches the schema" testCredentialResponseJSON + it "error response matches the schema" testErrorResponseJSON + it "statement entries round-trip, unknown entry types verbatim" testStatementJSON proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p @@ -140,3 +158,138 @@ issueBadgeProof bt expiry = do Right cred <- issueBadge testKeyIdx sk vreq Right badge <- generateBadgeProof pk cred (BBSPresHeader "test-nonce") pure (pk, badge) + +-- Redemption codes + +testCodeRoundTrip :: IO () +testCodeRoundTrip = do + drg <- C.newRandom + code <- randomBadgeCode drg + let formatted = formatBadgeCode code + T.length formatted `shouldBe` 27 -- SXB-XXXXX-XXXXX-XXXXX-XXXXX + T.take 4 formatted `shouldBe` "SXB-" + T.length (badgeCodeText code) `shouldBe` 23 -- the canonical form drops the separators + parseBadgeCode formatted `shouldBe` Just code + parseBadgeCode (badgeCodeText code) `shouldBe` Just code + +testCodeNormalisation :: IO () +testCodeNormalisation = do + drg <- C.newRandom + code <- randomBadgeCode drg + parseBadgeCode (T.toLower $ badgeCodeText code) `shouldBe` Just code + parseBadgeCode (T.replace "-" " " $ formatBadgeCode code) `shouldBe` Just code + -- a fixed code, because a random one contains no 0 or 1 about a quarter of the time and the + -- folding would then be asserted against nothing + let folded = T.map ambiguous fixedCode + folded `shouldNotBe` fixedCode + parseBadgeCode folded `shouldBe` parseBadgeCode fixedCode + parseBadgeCode fixedCode `shouldNotBe` Nothing + where + fixedCode = "SXB-0C0QS-XAQW1-N1VSA-R00Y3" + ambiguous = \case + '1' -> 'I' + '0' -> 'O' + c -> c + +testCodeCheckCharacter :: IO () +testCodeCheckCharacter = do + drg <- C.newRandom + code <- randomBadgeCode drg + let canonical = badgeCodeText code + -- every other value for the last character fails the check + wrong = T.init canonical <> T.singleton (if T.last canonical == 'Z' then 'Y' else 'Z') + parseBadgeCode wrong `shouldBe` Nothing + parseBadgeCode "" `shouldBe` Nothing + parseBadgeCode "SXB-00000-00000-00000-0000" `shouldBe` Nothing + parseBadgeCode (T.drop 3 canonical) `shouldBe` Nothing + +testCodeHash :: IO () +testCodeHash = do + drg <- C.newRandom + code <- randomBadgeCode drg + Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code + badgeCodeHash typed `shouldBe` badgeCodeHash code + +-- Service protocol JSON, against docs/protocol/badges-rpc.schema.json + +testRedeemRequestJSON :: IO () +testRedeemRequestJSON = do + drg <- C.newRandom + mk <- generateMasterKey drg + (k, _) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519) + code <- randomBadgeCode drg + let req = BadgeServiceRequest {version = Version 1, purchaseKey = Just k, request = BSCRedeemBadgeCode {masterKey = mk, code = badgeCodeText code}} + J.toJSON req + `shouldBe` J.object + [ "version" J..= (1 :: Int), + "purchaseKey" J..= k, + "request" J..= J.object ["type" J..= ("redeemBadgeCode" :: T.Text), "masterKey" J..= mk, "code" J..= badgeCodeText code] + ] + -- purchaseKey is optional in the schema, and a nullary command is a bare tagged object + J.toJSON BadgeServiceRequest {version = Version 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog} + `shouldBe` J.object ["version" J..= (1 :: Int), "request" J..= J.object ["type" J..= ("getBadgeCatalog" :: T.Text)]] + roundTrips req + +testCredentialResponseJSON :: IO () +testCredentialResponseJSON = do + Right (_, sk) <- bbsKeyGen + drg <- C.newRandom + mk <- generateMasterKey drg + let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just futureTime, badgeExtra = ""} + Right cred <- issueBadge testKeyIdx sk (VerifiedBadgeRequest BadgeRequest {masterKey = mk, badgeInfo = info}) + let resp = BSPBadgeCredential {credential = Just cred, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}} + J.toJSON resp + `shouldBe` J.object + [ "type" J..= ("badgeCredential" :: T.Text), + "credential" J..= cred, + "statement" J..= J.object ["entries" J..= ([] :: [J.Value])] + ] + roundTrips resp + +testErrorResponseJSON :: IO () +testErrorResponseJSON = do + let resp = BSPError {code = BSECodeInvalid, message = Nothing, retryAfter = Nothing} + J.toJSON resp `shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("code_invalid" :: T.Text)] + J.toJSON BSPError {code = BSERateLimited, message = Just "slow down", retryAfter = Just 30} + `shouldBe` J.object ["type" J..= ("error" :: T.Text), "code" J..= ("rate_limited" :: T.Text), "message" J..= ("slow down" :: T.Text), "retryAfter" J..= (30 :: Int)] + +testStatementJSON :: IO () +testStatementJSON = do + let entry = + StatementEntry + { entryId = "e1", + changeMonths = 3, + balanceMonths = 3, + balanceStartTs = futureTime, + balanceBadgeType = BTSupporter, + wasPausedSince = Nothing, + createdAt = futureTime, + entryType = SECredit {credit = SCPayment {invoiceId = Nothing}} + } + -- the whole entry: the required fields, and wasPausedSince omitted rather than sent as null + J.toJSON entry + `shouldBe` J.object + [ "entryId" J..= ("e1" :: T.Text), + "changeMonths" J..= (3 :: Int), + "balanceMonths" J..= (3 :: Int), + "balanceStartTs" J..= futureTime, + "balanceBadgeType" J..= ("supporter" :: T.Text), + "createdAt" J..= futureTime, + "entryType" J..= entryType entry + ] + J.toJSON entry {wasPausedSince = Just pastTime} `shouldNotBe` J.toJSON entry + J.toJSON (entryType entry) `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("payment" :: T.Text)]] + J.toJSON SEDebit {debit = SDBadge} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("badge" :: T.Text)]] + -- an entry type from a newer service is stored and re-emitted unchanged + let futureCredit = J.object ["type" J..= ("grant" :: T.Text), "grantedBy" J..= ("operator" :: T.Text)] + case J.fromJSON futureCredit of + J.Success c@SCUnknown {tag} -> do + tag `shouldBe` "grant" + J.toJSON c `shouldBe` futureCredit + r -> expectationFailure $ "expected SCUnknown, got " <> show (fmap (const ()) r) + +-- decoding and re-encoding reproduces the encoding, without Eq on the protocol types +roundTrips :: (HasCallStack, J.ToJSON a, J.FromJSON a) => a -> IO () +roundTrips x = case J.eitherDecode (J.encode x) of + Right x' -> J.toJSON (x' `asTypeOf` x) `shouldBe` J.toJSON x + Left e -> expectationFailure e diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 896c603d7f..84daff15f8 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -1,4 +1,6 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -10,17 +12,37 @@ import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Concurrent.STM (atomically, readTMVar) import Control.Exception (finally) -import Simplex.Chat.Controller (ChatConfig) +import qualified Data.ByteString.Char8 as B +import Data.Char (toLower) +import Data.Either (isLeft, isRight) +import qualified Data.Map.Strict as M +import qualified Data.Text as T +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) +import Simplex.Chat.Controller (ChatConfig (..), ChatController, ChatResponse (CRCustomChatResponse)) +import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BBS (BBSSecretKey, bbsKeyGen) +import Simplex.Messaging.Encoding.String (strDecode, strEncode, textEncode) import System.FilePath (()) import Test.Hspec hiding (it) badgeServiceTests :: SpecWith TestParams badgeServiceTests = do - it "should respond with unsupported_version to redeem" testBadgeServiceRedeemUnsupported + it "should answer unsupported_version to unsupported command" testBadgeServiceUnsupported + it "should redeem an issued code into a badge a contact sees" testRedeemBadgeCode + it "should return the same badge when the same code is redeemed twice" testRedeemBadgeCodeTwice + it "should answer code_invalid to an unknown code, indistinguishably from a malformed one" testRedeemUnknownCode + it "should tell a second profile redeeming the same code that it is used" testRedeemSameCodeOtherProfile + it "should redeem a second code, and not restore the first badge on replay" testRedeemSecondCode + it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments + it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch + it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig badgeProfile :: Profile badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -28,8 +50,11 @@ badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDesc serviceDbPrefix :: FilePath serviceDbPrefix = "badge_service" -mkBadgeServiceOpts :: TestParams -> BadgeServiceOpts -mkBadgeServiceOpts TestParams {tmpPath = ps} = +testIssuerKeyIdx :: Int +testIssuerKeyIdx = 1 + +mkBadgeServiceOpts :: TestParams -> BBSSecretKey -> BadgeServiceOpts +mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = BadgeServiceOpts { coreOptions = testCoreOpts @@ -45,15 +70,21 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} = clientService = True, noAddress = False, runCLI = False, + issuerKey = Just BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey}, testing = True } -withBadgeService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () +-- | Start the badge service on a fresh issuer key, and hand the test body what depends on it: +-- the client config trusting that key and addressing the service, the address, and the controller. +withBadgeService :: HasCallStack => TestParams -> (ChatConfig -> String -> ChatController -> IO ()) -> IO () withBadgeService ps test = do - let opts = mkBadgeServiceOpts ps + Right (pk, sk) <- bbsKeyGen + let opts = mkBadgeServiceOpts ps sk + -- the service refuses to start unless its secret is the key trusted at its index + svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk} withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () -- First start: badge service takes the CreateMyAddress branch. - runBadgeService testCfg opts (pure ()) + runBadgeService svcCfg opts $ \_ -> pure () -- Reopen the DB to read the link the service created. bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do bs <## "subscribed 1 connections on server localhost" @@ -61,21 +92,205 @@ withBadgeService ps test = do (sLink, _) <- getContactLinks bs False bs <## "auto_accept off" pure sLink + let clientCfg = + svcCfg {badgeServiceAddress = Just $ either (error . ("bad badge service address: " <>)) id $ strDecode (B.pack bsLink)} -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. - runBadgeService testCfg opts $ - withNewTestChatCfg ps testCfg "client" bobProfile $ \client -> - test client bsLink + runBadgeService svcCfg opts $ \env -> do + cc <- atomically $ readTMVar $ serviceCC env + test clientCfg bsLink cc -runBadgeService :: ChatConfig -> BadgeServiceOpts -> IO () -> IO () +-- through the operator command the service actually exposes, not the function behind it +issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode +issueCode cc badgeType months = + sendChatCmdStr cc ("//issue " <> T.unpack (textEncode badgeType) <> " " <> show months) >>= \case + Right (CRCustomChatResponse _ response) -> case T.stripPrefix "code " response of + Just c | Just code <- parseBadgeCode c -> pure code + _ -> error $ "unexpected issue response: " <> T.unpack response + r -> error $ "issue failed: " <> show (() <$ r) + +runBadgeService :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () runBadgeService cfg opts action = do - t <- forkIO $ badgeService opts cfg + env <- newServiceState + t <- forkIO $ badgeService opts cfg env threadDelay 500000 - action `finally` killThread t + action env `finally` killThread t -testBadgeServiceRedeemUnsupported :: HasCallStack => TestParams -> IO () -testBadgeServiceRedeemUnsupported ps = - withBadgeService ps $ \client bsLink -> do - let redeemReq = - "{\"version\":1,\"request\":{\"type\":\"purchaseBadge\",\"payment\":{\"type\":\"code\",\"code\":\"TEST-CODE\"}}}" - client ##> ("/_service_request 1 " <> bsLink <> " " <> redeemReq) - client <## "service response: {\"code\":\"unsupported_version\",\"type\":\"error\"}" +codeArg :: BadgeCode -> String +codeArg = T.unpack . formatBadgeCode + +testBadgeServiceUnsupported :: HasCallStack => TestParams -> IO () +testBadgeServiceUnsupported ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "client" bobProfile $ \client -> do + let req = "{\"version\":1,\"request\":{\"type\":\"pauseBadge\"}}" + client ##> ("/_service_request 1 " <> bsLink <> " " <> req) + client <## "service response: {\"code\":\"unsupported_version\",\"type\":\"error\"}" + +testRedeemBadgeCode :: HasCallStack => TestParams -> IO () +testRedeemBadgeCode ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps clientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 1 + -- the service has never seen this purchase key: a first redemption must still succeed + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + alice #> "@bob hi" + bob <# "alice *> hi" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +testRedeemBadgeCodeTwice :: HasCallStack => TestParams -> IO () +testRedeemBadgeCodeTwice ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + -- Retyped in another case and without separators, so it normalises to the same code and + -- finds the same stashed keys: a retry the service can recognise as the same signer. + alice ##> ("/_redeem_badge_code 1 " <> map toLower (T.unpack $ badgeCodeText code)) + alice <## "badge already redeemed" + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + +-- The service answers unknown and malformed alike; the client refuses malformed locally, which +-- is why the two reach the user differently. +testRedeemUnknownCode :: HasCallStack => TestParams -> IO () +testRedeemUnknownCode ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + unknown <- randomBadgeCode g + alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) + alice <## "bad chat command: badge service error: code_invalid" + -- a failed check character is refused before anything leaves the device + alice ##> "/_redeem_badge_code 1 SXB-00000-00000-00000-00001" + alice <## "bad chat command: invalid badge code" + -- sent straight to the service, past the client's own check, the two are one answer + (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) + alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" + redeemDirect alice bsLink redeemPriv "SXB-00000-00000-00000-00001" + alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" + +-- a signed redeemBadgeCode sent as a raw service request, bypassing the client's own checks +redeemDirect :: HasCallStack => TestCC -> String -> C.PrivateKeyEd25519 -> String -> IO () +redeemDirect cc bsLink signPriv code = do + let purchaseKey = B.unpack $ strEncode $ C.publicKey signPriv + signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + req = + "{\"version\":1,\"purchaseKey\":\"" <> purchaseKey + <> "\",\"request\":{\"type\":\"redeemBadgeCode\",\"masterKey\":\"" <> testMasterKeyB64 + <> "\",\"code\":\"" <> code <> "\"}}" + cc ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + +-- any 32 bytes: these requests never reach signing +testMasterKeyB64 :: String +testMasterKeyB64 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + +-- BadgeType decodes anything to BTUnknown, so without a check at this boundary an operator +-- typo would issue a code no app can show as a badge. +testIssueRejectsBadArguments :: HasCallStack => TestParams -> IO () +testIssueRejectsBadArguments ps = + withBadgeService ps $ \_ _ cc -> do + let refuses arg = issueRaw cc arg >>= (`shouldSatisfy` isLeft) + refuses "suporter" + refuses "supporter 0" + refuses "supporter 256" + refuses "supporter 1 gratis" + refuses "" + issueRaw cc "supporter 255 paid" >>= (`shouldSatisfy` isRight) + +issueRaw :: ChatController -> String -> IO (Either () ()) +issueRaw cc args = + sendChatCmdStr cc ("//issue " <> args) >>= \case + Right CRCustomChatResponse {} -> pure $ Right () + _ -> pure $ Left () + +-- Both purchases fund no payment, so both leave payment_id NULL under UNIQUE(payment_id). +-- The first purchase stays as it is: retiring a superseded badge is not implemented. +testRedeemSecondCode :: HasCallStack => TestParams -> IO () +testRedeemSecondCode ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + supporter <- issueCode cc BTSupporter 1 + legend <- issueCode cc BTLegend 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg supporter) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) + alice <## "badge redeemed" + alice <## "legend badge - active" + alice <##. "expires " + alice ##> "/p" + showActiveUser alice "alice (Alice, * legend)" + -- replaying the first code must not put supporter back + alice ##> ("/_redeem_badge_code 1 " <> codeArg supporter) + alice <## "badge already redeemed" + alice ##> "/p" + showActiveUser alice "alice (Alice, * legend)" + +-- Each profile stashes its own keys, so the second reaches the service as a different signer - +-- rather than being handed the first profile's badge, or colliding in badge_code_redemptions. +testRedeemSameCodeOtherProfile :: HasCallStack => TestParams -> IO () +testRedeemSameCodeOtherProfile ps = + withBadgeService ps $ \clientCfg _ cc -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> ("/_redeem_badge_code 2 " <> codeArg code) + alice <## "bad chat command: badge service error: code_used" + alice ##> "/p" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice, * supporter)" + +testPurchaseKeyMismatch :: HasCallStack => TestParams -> IO () +testPurchaseKeyMismatch ps = + withBadgeService ps $ \clientCfg bsLink _ -> + withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do + g <- C.newRandom + (_, signPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (claimedPub, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + let signKey = B.unpack $ strEncode (C.StoredPrivateKey signPriv) + claimed = B.unpack $ strEncode claimedPub + req = "{\"version\":1,\"purchaseKey\":\"" <> claimed <> "\",\"request\":{\"type\":\"pauseBadge\"}}" + alice ##> ("/_service_request 1 " <> bsLink <> " sign_key=" <> signKey <> " " <> req) + alice <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}" + +-- A secret that is not the key clients trust at its index makes every credential unverifiable, +-- and each code redeemed against it is spent for good - so the service must not start at all. +testIssuerKeyMustMatchConfig :: HasCallStack => TestParams -> IO () +testIssuerKeyMustMatchConfig ps = do + Right (pk, sk) <- bbsKeyGen + Right (_, otherSk) <- bbsKeyGen + let optsFor sk' = mkBadgeServiceOpts ps sk' + cfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk} + checkIssuerKey (optsFor sk) cfg >>= (`shouldSatisfy` isRight) + checkIssuerKey (optsFor otherSk) cfg >>= (`shouldSatisfy` isLeft) + -- an index no client trusts is equally fatal + checkIssuerKey (optsFor sk) testCfg {badgePublicKeys = M.empty} >>= (`shouldSatisfy` isLeft) From f9bab17643650d4e947dce008174cdf67fb3d172 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:31:05 +0000 Subject: [PATCH 10/16] core: renew badges monthly and alert when support ends (#7448) --- .../src/BadgeService/Service.hs | 177 ++-- .../src/BadgeService/Store.hs | 189 +++- bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Events.hs | 2 + bots/src/API/Docs/Responses.hs | 1 + docs/protocol/badges-rpc.md | 8 +- docs/protocol/badges-rpc.schema.json | 11 +- plans/2026-07-30-supporter-badges-v3-ux.md | 21 +- plans/2026-09-01-badges-renewal-alerts.md | 151 ++++ plans/2026-09-07-badge-ledger-types.md | 103 +++ .../2026-09-07-badge-worker-simplification.md | 168 ++++ simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 6 + src/Simplex/Chat/Badges/Code.hs | 4 +- src/Simplex/Chat/Badges/Ledger.hs | 176 ++++ src/Simplex/Chat/Badges/Service.hs | 9 +- src/Simplex/Chat/Badges/Types.hs | 79 +- src/Simplex/Chat/Controller.hs | 22 + src/Simplex/Chat/Library/Commands.hs | 433 +++++++++- src/Simplex/Chat/Store/Badges.hs | 253 +++++- .../Migrations/M20261001_user_badges.hs | 6 + .../Store/Postgres/Migrations/chat_schema.sql | 8 +- src/Simplex/Chat/Store/Profiles.hs | 28 +- .../Migrations/M20261001_user_badges.hs | 6 + .../SQLite/Migrations/chat_query_plans.txt | 123 ++- .../Store/SQLite/Migrations/chat_schema.sql | 8 +- src/Simplex/Chat/Store/Shared.hs | 1 + src/Simplex/Chat/View.hs | 27 +- tests/BadgeTests.hs | 291 ++++++- tests/Bots/BadgeServiceTests.hs | 806 +++++++++++++++++- 30 files changed, 2901 insertions(+), 220 deletions(-) create mode 100644 plans/2026-09-01-badges-renewal-alerts.md create mode 100644 plans/2026-09-07-badge-ledger-types.md create mode 100644 plans/2026-09-07-badge-worker-simplification.md create mode 100644 src/Simplex/Chat/Badges/Ledger.hs diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 1f3299168e..8337f672a4 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -2,6 +2,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TupleSections #-} module BadgeService.Service ( ServiceState (..), @@ -10,6 +11,8 @@ module BadgeService.Service checkIssuerKey, badgeService, badgeServiceCLI, + badgeServiceResponse, + badgeErrorRetryAfter, IssueCodeOpts (..), issueBadgeCode, ) @@ -28,15 +31,16 @@ import qualified Data.Aeson.KeyMap as KM import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import Data.Char (isSpace) +import Data.Either (fromRight) import Data.Functor (($>)) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, maybeToList) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Data.Time.Calendar (addDays, addGregorianMonthsClip) -import Data.Time.Calendar.WeekDate (toWeekDate) -import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Word (Word32) import Simplex.Chat.Badges import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot (initializeBotAddress') @@ -224,7 +228,16 @@ responseObject r = case J.toJSON r of _ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON BSEInternal)] errorResponse :: BadgeServiceErrorCode -> BadgeServiceResponse -errorResponse code = BSPError {code, message = Nothing, retryAfter = Nothing} +errorResponse code = BSPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code} + +-- | Seconds, for the three codes badges-rpc.md marks transient. Every other code is terminal for +-- the command attempted - internal included, which would otherwise press a failing service. +badgeErrorRetryAfter :: BadgeServiceErrorCode -> Maybe Word32 +badgeErrorRetryAfter = \case + BSEPaymentPending -> Just 300 + BSEProviderUnavailable -> Just 300 + BSERateLimited -> Just 60 + _ -> Nothing -- | The agent verified the signature, so sigKey is a key the sender holds - a purchaseKey that @@ -239,6 +252,9 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc k masterKey code Nothing -> pure $ errorResponse BSEBadRequest + BSCIssueBadge {balance} -> case purchaseKey of + Just k -> issueBadgeCmd key cc k balance + Nothing -> pure $ errorResponse BSEBadRequest -- every command but redeemBadgeCode needs a key the service already knows: that one -- creates the purchase, so its key is unknown on a first redemption _ -> case purchaseKey of @@ -249,64 +265,115 @@ badgeServiceResponse key cc sigKey reqData = case J.fromJSON (J.Object reqData) Right False -> pure $ errorResponse BSEUnknownPurchaseKey Left _ -> pure $ errorResponse BSEInternal +-- | The only clock the service reads, so a test can move both sides of a request together. +badgeNow :: ChatController -> IO UTCTime +badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime + +randomId :: ChatController -> IO T.Text +randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc)) + +-- | Neither value comes from the caller: the badge type is the entry's, the expiry is derived +-- from the period end it carries. +credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential)) +credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do + let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""} + fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) + +-- | Pairs the issued entry with the one before it, which the writer needs for the period start. +issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential) +issuanceAfter previous (issued, credential) = (previous, issued, credential) + +credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] -> BadgeServiceResponse +credentialResponse credential previousEntryId entries = + BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}} + -- | Nothing is written until the credential is signed, so a signing failure leaves the code -- unspent rather than spent with nothing behind it. redeemCode :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse -redeemCode BadgeIssuerKey {keyIdx, secretKey} cc purchaseKey masterKey codeText = case parseBadgeCode codeText of +redeemCode key cc purchaseKey masterKey codeText = case parseBadgeCode codeText of Nothing -> pure $ errorResponse BSECodeInvalid Just code -> - withDB' "getBadgeCode" cc (`getBadgeCode` badgeCodeHash code) >>= \case + withDB "getBadgeCode" cc (readCode code) >>= \case Left _ -> pure $ errorResponse BSEInternal - Right Nothing -> pure $ errorResponse BSECodeInvalid - Right (Just IssuedCode {badgeCodeId, badgeType, redemption}) -> case redeemedResponse redemption of - Just resp -> pure resp - Nothing -> do - now <- getCurrentTime - -- TODO [badges] the code's months are ignored until the ledger credits them - let periodEnd = addMonths 1 now - badgeInfo = BadgeInfo {badgeType, badgeExpiry = endOfSundayAfter periodEnd, badgeExtra = ""} - issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) >>= \case + Right (Left resp) -> pure resp + Right (Right IssuedCode {badgeCodeId, badgeType, months}) -> do + now <- badgeNow cc + (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + -- the purchase is created here, so there is no ledger to lapse + -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the + -- months it adds are counted from a start already in the past + let granted = grantEntry now grantUuid months SCCode $ emptyEntry now badgeType + -- a grant of at least one month starting now always has a month to issue + case issueEntry now issueUuid granted of + Nothing -> pure $ errorResponse BSEInternal + Just issued -> credentialForEntry key masterKey issued >>= \case Left e -> logError ("badge service signing failed: " <> T.pack e) $> errorResponse BSEInternal - Right credential -> do - issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 $ random cc) - let newRedemption = - NewBadgeCodeRedemption - { badgeCodeId, - issuanceId, - purchaseKey, - masterKey, - badgeType, - credential, - periodStart = now, - periodEnd, - expiry = endOfSundayAfter periodEnd - } + Right signed -> do -- re-read: a concurrent redemption may have landed while this one was signing r <- withDB "writeCodeRedemption" cc $ \db -> - liftIO (getBadgeCode db $ badgeCodeHash code) >>= \case - Just IssuedCode {redemption = current} | Just resp <- redeemedResponse current -> pure resp - _ -> liftIO $ credentialResponse credential <$ writeCodeRedemption db newRedemption now - pure $ either (const $ errorResponse BSEInternal) id r + readCode code db >>= \case + Left resp -> pure resp + Right _ -> liftIO $ do + purchaseId <- createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now + appendLedgerPlan db purchaseId [granted] $ Just $ issuanceAfter granted signed + entries_ <- getLedgerEntries db purchaseId 0 + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ + pure $ fromRight (errorResponse BSEInternal) r where - -- one definition, used before signing and again inside the write transaction - redeemedResponse = \case - CodeUnredeemed -> Nothing - CodeRedeemedUnreadable -> Just $ errorResponse BSEInternal - CodeRedeemed RedeemedCode {purchaseKey = k, credential} - | k == purchaseKey -> Just $ credentialResponse credential - | otherwise -> Just $ errorResponse BSECodeUsed + -- used before signing and again inside the write transaction; every Left is a finished + -- response, an unknown code included + readCode code db = liftIO $ + getBadgeCode db (badgeCodeHash code) >>= \case + Nothing -> pure $ Left $ errorResponse BSECodeInvalid + Just c@IssuedCode {redemption} -> fmap (const c) <$> checkUnspent db redemption + checkUnspent db = \case + CodeUnredeemed -> pure $ Right () + CodeRedeemedUnreadable -> pure $ Left $ errorResponse BSEInternal + CodeRedeemed RedeemedCode {purchaseKey = k, badgePurchaseId, credential} + | k /= purchaseKey -> pure $ Left $ errorResponse BSECodeUsed + -- the whole ledger, so a client that lost the first response still ends holding it + | otherwise -> + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + <$> getLedgerEntries db badgePurchaseId 0 --- TODO [badges] the statement is empty until the ledger is written -credentialResponse :: BadgeCredential -> BadgeServiceResponse -credentialResponse credential = - BSPBadgeCredential {credential = Just credential, receipt = Nothing, statement = BadgeStatement {entries = [], previousEntryId = Nothing}} - -addMonths :: Integer -> UTCTime -> UTCTime -addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t - --- Every badge in a week expires together, revealing nothing about when it was bought. --- The end of a Sunday is the next Monday at 00:00, so this returns a Monday and 8 is right. -endOfSundayAfter :: UTCTime -> UTCTime -endOfSundayAfter (UTCTime d _) = - let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday - in UTCTime (addDays (toInteger (8 - dayOfWeek)) d) 0 +-- | The purchase is reached through the verified signer key and no other way. +issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO BadgeServiceResponse +issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do + now <- badgeNow cc + purchase_ <- withDB' "getBadgePurchase" cc $ \db -> do + p_ <- getPurchaseByKey db purchaseKey + forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,) <$> getLedgerTip db badgePurchaseId + case purchase_ of + Left _ -> pure $ errorResponse BSEInternal + Right Nothing -> pure $ errorResponse BSEUnknownPurchaseKey + Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do + (lapseUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + let tipEntry = fromMaybe (emptyEntry now badgeType) tip + lapsed = lapseEntry now lapseUuid tipEntry + current = fromMaybe tipEntry lapsed + case issueEntry now issueUuid current of + Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing + Just e -> + credentialForEntry key masterKey e >>= \case + Left err -> logError ("badge service signing failed: " <> T.pack err) $> errorResponse BSEInternal + Right signed -> + writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed + where + -- the rows were computed from a tip that another request may have moved, and an issuance was + -- signed against it - so write only if it is still the tip + writeIssued purchaseId tip rows t issuance_ = do + r <- withDB "issueBadge" cc $ \db -> liftIO $ do + tip' <- getLedgerTip db purchaseId + when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId rows issuance_ + issueResponse db purchaseId t + pure $ fromRight (errorResponse BSEInternal) r + -- entries after the one asserted, or the whole ledger when this purchase does not hold it. + -- Only the asserted entry's identity is read, never the months it claims. + issueResponse db purchaseId t = do + let StatementEntry {entryId = assertedUuid} = lastEntry + assertedId <- getLedgerEntryId db purchaseId assertedUuid + -- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a + -- single opening credit (badges-rpc.md), rather than resending the whole history + entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId) + credential_ <- getCurrentIssuance db purchaseId t + pure $ maybe (errorResponse BSEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_ diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index 3df619b331..dac51a5793 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -9,10 +9,17 @@ module BadgeService.Store ( IssuedCode (..), CodeRedemption (..), RedeemedCode (..), - NewBadgeCodeRedemption (..), + NewCodePurchase (..), + ServicePurchase (..), getBadgeCode, purchaseKeyExists, - writeCodeRedemption, + getPurchaseByKey, + getLedgerTip, + getLedgerEntryId, + getLedgerEntries, + getCurrentIssuance, + appendLedgerPlan, + createCodePurchase, insertBadgeCode, ) where @@ -24,6 +31,8 @@ import Data.Int (Int64) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges (BadgeCredential, BadgeMasterKey (..), BadgeType) +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service (StatementEntry (..)) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Messaging.Agent.Store.DB (Binary (..)) @@ -32,16 +41,17 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else -import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif data IssuedCode = IssuedCode { badgeCodeId :: Int64, badgeType :: BadgeType, + months :: Int, redemption :: CodeRedemption } @@ -53,23 +63,24 @@ data CodeRedemption | CodeRedeemedUnreadable data RedeemedCode = RedeemedCode - { purchaseKey :: C.PublicKeyEd25519, + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, credential :: BadgeCredential } --- Everything writeCodeRedemption inserts, so that the purchase, its issuance and the spent code --- are written in one transaction. If the code were marked redeemed and one of the other writes --- failed, it would be spent with no credential behind it, and nothing can reissue it. -data NewBadgeCodeRedemption = NewBadgeCodeRedemption +-- Its rows and issuance are appended by 'appendLedgerPlan' in the same transaction: a code marked +-- redeemed while another write failed would be spent with no credential, and nothing reissues it. +data NewCodePurchase = NewCodePurchase { badgeCodeId :: Int64, - issuanceId :: Text, purchaseKey :: C.PublicKeyEd25519, masterKey :: BadgeMasterKey, - badgeType :: BadgeType, - credential :: BadgeCredential, - periodStart :: UTCTime, - periodEnd :: UTCTime, - expiry :: UTCTime + badgeType :: BadgeType + } + +data ServicePurchase = ServicePurchase + { badgePurchaseId :: Int64, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType } getBadgeCode :: DB.Connection -> ByteString -> IO (Maybe IssuedCode) @@ -78,23 +89,23 @@ getBadgeCode db codeHash = DB.query db [sql| - SELECT c.badge_code_id, c.badge_type, p.purchase_key, i.credential + SELECT c.badge_code_id, c.badge_type, c.months, p.badge_purchase_id, p.purchase_key, i.credential FROM sx_badge_service_badge_codes c LEFT JOIN sx_badge_service_badge_purchases p ON p.badge_code_id = c.badge_code_id LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id WHERE c.code_hash = ? - ORDER BY i.created_at DESC + ORDER BY i.period_end DESC LIMIT 1 |] (Only (Binary codeHash)) where - toCode (badgeCodeId, badgeType, purchaseKey_, credential_) = - IssuedCode {badgeCodeId, badgeType, redemption = codeRedemption purchaseKey_ credential_} - codeRedemption purchaseKey_ credential_ = case purchaseKey_ of - Nothing -> CodeUnredeemed - Just purchaseKey -> case decodeCredential =<< credential_ of - Just credential -> CodeRedeemed RedeemedCode {purchaseKey, credential} + toCode (badgeCodeId, badgeType, months, purchaseId_, purchaseKey_, credential_) = + IssuedCode {badgeCodeId, badgeType, months, redemption = codeRedemption purchaseId_ purchaseKey_ credential_} + codeRedemption purchaseId_ purchaseKey_ credential_ = case (purchaseId_, purchaseKey_) of + (Just badgePurchaseId, Just purchaseKey) -> case decodeCredential =<< credential_ of + Just credential -> CodeRedeemed RedeemedCode {badgePurchaseId, purchaseKey, credential} Nothing -> CodeRedeemedUnreadable + _ -> CodeUnredeemed decodeCredential (Binary bs) = J.decodeStrict' bs purchaseKeyExists :: DB.Connection -> C.PublicKeyEd25519 -> IO Bool @@ -102,9 +113,126 @@ purchaseKeyExists db key = maybeFirstRow' False (\(Only (_ :: Int64)) -> True) $ DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key) --- one transaction: the caller has already signed, so no code is left spent without a credential -writeCodeRedemption :: DB.Connection -> NewBadgeCodeRedemption -> UTCTime -> IO () -writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType, credential, periodStart, periodEnd, expiry} now = do +-- | The only route from a command to a purchase, so a client cannot name one it cannot sign for. +getPurchaseByKey :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe ServicePurchase) +getPurchaseByKey db key = + maybeFirstRow toPurchase $ + DB.query + db + [sql| + SELECT badge_purchase_id, master_key, current_badge_type + FROM sx_badge_service_badge_purchases + WHERE purchase_key = ? + |] + (Only key) + where + toPurchase (badgePurchaseId, Binary mk, badgeType) = + ServicePurchase {badgePurchaseId, masterKey = BadgeMasterKey mk, badgeType} + +getLedgerTip :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) +getLedgerTip db purchaseId = + maybeFirstRow' Nothing toEntry $ + DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + entry_type, entry_credit_type, entry_debit_type, service_created_at + FROM sx_badge_service_badge_ledger + WHERE badge_purchase_id = ? + ORDER BY entry_id DESC + LIMIT 1 + |] + (Only purchaseId) + +-- | The uuid is the client's claim about its last held entry, so the lookup is scoped to its own +-- purchase - an entry_id taken from another ledger would silently skip rows of this one. +getLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64) +getLedgerEntryId db purchaseId entryUuid = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT entry_id FROM sx_badge_service_badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" + (purchaseId, entryUuid) + +-- | 0 for the whole ledger, as entry_id starts at 1. 'Nothing' when a stored row has a type this +-- version cannot represent, rather than sending it changed into another. +getLedgerEntries :: DB.Connection -> Int64 -> Int64 -> IO (Maybe [StatementEntry]) +getLedgerEntries db purchaseId afterEntryId = + mapM toEntry + <$> DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + entry_type, entry_credit_type, entry_debit_type, service_created_at + FROM sx_badge_service_badge_ledger + WHERE badge_purchase_id = ? AND entry_id > ? + ORDER BY entry_id + |] + (purchaseId, afterEntryId) + +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) -> Maybe StatementEntry +toEntry (entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) + <$> entryTypeFromColumns entryType_ credit_ debit_ + +-- | Answers a repeat inside an issued month, rather than signing the same content twice. +getCurrentIssuance :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe BadgeCredential) +getCurrentIssuance db purchaseId now = do + rs <- + DB.query + db + [sql| + SELECT credential FROM sx_badge_service_badge_issuances + WHERE badge_purchase_id = ? AND period_end > ? + ORDER BY period_end DESC + LIMIT 1 + |] + (purchaseId, now) + pure $ case rs of + [Only (Binary bs)] -> J.decodeStrict' bs + _ -> Nothing + +-- | The issuance is the entry that spends the month and the one before it, which give the period. +-- TODO [badges] also write the reference columns - payment_id, charge_id, from_purchase_id, +-- to_purchase_id - for the entry types that carry one. Only the tag is written today, so a +-- payment, charge, transferIn, upgrade or transferOut row would be stored without its reference. +appendLedgerPlan :: DB.Connection -> Int64 -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () +appendLedgerPlan db purchaseId rows issuance_ = do + mapM_ appendRow rows + case issuance_ of + Nothing -> pure () + Just (previous, issued@StatementEntry {entryId, balanceStartTs = periodEnd, balanceBadgeType, createdAt}, credential) -> do + rowId <- appendRow issued + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_issuances + (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + |] + -- the issued entry's uuid is the issuance id: one issuance per such entry, and entry uuids + -- are already unique across the ledger, so nothing has to be drawn for it + ( (entryId, purchaseId, rowId, balanceBadgeType) + :. (balanceStartTs previous, periodEnd, endOfMondayAfter periodEnd, Binary (LB.toStrict $ J.encode credential), createdAt) + ) + where + appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do + let (entryTypeT, creditType, debitType) = entryTypeColumns entryType + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType)) + insertedRowId db + +-- redeemed_at is stamped here, so this must share a transaction with the credential's rows: +-- a code marked spent without one can never be reissued +createCodePurchase :: DB.Connection -> NewCodePurchase -> UTCTime -> IO Int64 +createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do DB.execute db [sql| @@ -114,15 +242,8 @@ writeCodeRedemption db NewBadgeCodeRedemption {badgeCodeId, issuanceId, purchase |] (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, badgeCodeId, now, now) purchaseId <- insertedRowId db - DB.execute - db - [sql| - INSERT INTO sx_badge_service_badge_issuances - (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) - VALUES (?,?,?,?,?,?,?,?) - |] - (issuanceId, purchaseId, badgeType, periodStart, periodEnd, expiry, Binary (LB.toStrict $ J.encode credential), now) DB.execute db "UPDATE sx_badge_service_badge_codes SET redeemed_at = ? WHERE badge_code_id = ?" (now, badgeCodeId) + pure purchaseId insertBadgeCode :: DB.Connection -> ByteString -> BadgeType -> Int -> BadgeCodePaymentStatus -> UTCTime -> IO () insertBadgeCode db codeHash badgeType months paymentStatus now = diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 052afcb6e6..7b051bdb13 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -336,6 +336,7 @@ undocumentedCommands = [ "APIAbortSwitchContact", "APIAbortSwitchGroupMember", "APIAcceptConditions", + "APIAckBadgeAlert", "APIActivateChat", "APIAddGroupShortLink", "APIAddMyAddressShortLink", @@ -371,6 +372,7 @@ undocumentedCommands = "APIExportArchive", "APIForwardChatItems", "APIGetAppSettings", + "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", "APIGetChatContentTypes", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 1590ad54d5..598d6a5a04 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -172,6 +172,8 @@ undocumentedEvents = "CEvtAgentConnsDeleted", "CEvtAgentRcvQueuesDeleted", "CEvtAgentUserDeleted", + "CEvtBadgeAlert", + "CEvtBadgeChanged", "CEvtBusinessRequestAlreadyAccepted", "CEvtCallAnswer", "CEvtCallEnded", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 151a238054..65cf9bada5 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -130,6 +130,7 @@ undocumentedResponses = "CRArchiveExported", "CRArchiveImported", "CRBadgeRedeemed", + "CRBadgeState", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 9b0b743dec..8ea70e1d19 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -27,7 +27,7 @@ A timeout hides the outcome, so the client repeats the identical signed request ## Commands -`purchaseBadge`, `upgradeBadgeSubscription`, and `issueBadge` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. +`purchaseBadge` and `upgradeBadgeSubscription` carry `badgeRequest`, the signer's input (`BadgeRequest`, `Simplex.Chat.Badges`): the service signs exactly this content or rejects the command. The proposed `badgeExpiry` is capped by the funded coverage (`sundayAfter`, model §3) and is required — a credential always expires, and a badge that does not is expressed as a long finite term; `badgeExtra` is reserved and must be empty. `issueBadge` carries no `badgeRequest`: the tier, the master key and the expiry are all the service's own, so there is nothing for the client to state. - `getBadgeCatalog` → `badgeCatalog` — the prices and offers; signed, also the purchase's `badgeStatement`. Store builds never send it: prices come from the store and SKUs from app config. - `getBadgeInvoice` → `badgeInvoice` — prices the purchase for `badgeInfo` and `paymentVia` (`card` — Stripe; `crypto` — btc, xmr). The response holds the generic `invoice` — `invoiceId`, `price`, `discount`, the upgrade `credit`, `amount` = price − discount − credit, `currency`, `expiresAt`, and `paymentTo` (`url` for card; `address` and `cryptoAmount` for crypto) — beside the badge part, `badgeType` and `months`. `priceId` pins the price the client displayed; `offerId` selects a discounted duration, and its absence buys one month at that price. Price and offer status is checked here only: `deprecated` is still accepted, `disabled` is rejected; a badge type with no active price yields `product_unavailable`. @@ -35,7 +35,7 @@ A timeout hides the outcome, so the client repeats the identical signed request - `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` token via the Publisher API; `invoice` against webhook-confirmed settlement, `payment_pending` until it lands; `receipt`), records the credit, and issues the first credential, in one round trip. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - Funding by `receipt` is a transfer (post-MVP): the unissued months of the purchase that receipt belongs to move to the signing key, recorded as `debit(transferOut)` on the source and `credit(transferIn)` on the new purchase, and the presented receipt is retired for a fresh one. The transferred period's issuance debits a month like any other. Lifetime badges hold no receipt, so support handles them. - `upgradeBadgeSubscription` → `badgeCredential` — the app-led store subscription change, on the same key: verifies the store evidence of the replaced subscription and records the new plan; an immediate upgrade returns the new credential, a deferred change returns none. -- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). +- `issueBadge` → `badgeCredential` — issues the next period from the balance, the only source of issuance. It carries `balance` alone: the credential is signed with the purchase's stored master key, for the type the balance funds, expiring at the `sundayAfter` of the period issued. The ledger is advanced first; the credential is signed before the `debit(badge)` and issuance rows are written, in one transaction. An exhausted balance yields no `credential`; the `statement` shows why. Issuing on a paused badge resumes it (model 2.13). - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). ## Upgrades @@ -53,9 +53,9 @@ Always a new purchase under a new key, except store subscriptions, where the sto ## Statement and balance -The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. +The ledger is written by the service alone (model §3); the client keeps a verbatim replica and computes the effective balance from its last entry and the time. Month boundaries are counted from `balanceAnchorTs`, the start of the current run of months, and not from `balanceStartTs` — counting from the moving start would compound the day-of-month clipping of a short month, so a run beginning 31 January would reach 28 February and never return to the 31st. The service sets a new anchor only where a lapsed run restarts; months granted while coverage still runs extend it on its existing anchor. -`statement` — `entries`, and `previousEntryId` when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states `entryId`, the signed `changeMonths`, the resulting `balanceMonths`, `balanceStartTs`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType` — `credit`: `payment {invoiceId?}`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. An unknown type is stored as received and decoded after an app upgrade. +`statement` — `entries`, and `previousEntryId` when they attach after an entry the client holds; its absence marks entries that attach to nothing. Each entry states `entryId`, the signed `changeMonths`, the resulting `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, and `balanceBadgeType`, `wasPausedSince` on the entry ending a pause, `createdAt`, and `entryType` — `credit`: `payment {invoiceId?}`, `code`, `charge {chargeId}`, `support`, `transferIn {fromPurchaseKey}`, `opening`; `debit`: `refund`, `upgrade {toPurchaseKey}`, `transferOut {toPurchaseKey}`, `support`, `badge`, `lapse`. A code grant is `code` rather than `payment` with no `invoiceId`: the invoice of a code belongs to whoever bought it, and the redeemer's ledger must never reference it. An unknown type is stored as received and decoded after an app upgrade. `balance` — `lastEntry`, the client's last entry, asserting the position and the months it believes it holds. diff --git a/docs/protocol/badges-rpc.schema.json b/docs/protocol/badges-rpc.schema.json index 9347d2fd64..01581a5bac 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -191,6 +191,10 @@ "changeMonths": {"type": "int16"}, "balanceMonths": {"type": "uint16"}, "balanceStartTs": {"type": "timestamp"}, + "balanceAnchorTs": { + "type": "timestamp", + "metadata": {"comment": "start of the current run of months; boundaries are counted from it"} + }, "balanceBadgeType": {"type": "string"}, "createdAt": {"type": "timestamp"}, "entryType": {"ref": "ledgerEntryType"} @@ -217,10 +221,14 @@ "optionalProperties": { "invoiceId": { "type": "string", - "metadata": {"comment": "absent for store and code payments"} + "metadata": {"comment": "absent for store payments"} } } }, + "code": { + "optionalProperties": {}, + "metadata": {"comment": "a redeemed code; its invoice belongs to the buyer, not the redeemer"} + }, "charge": { "properties": {"chargeId": {"type": "string"}} }, @@ -323,7 +331,6 @@ }, "issueBadge": { "properties": { - "badgeRequest": {"ref": "badgeRequest"}, "balance": {"ref": "balance"} } }, diff --git a/plans/2026-07-30-supporter-badges-v3-ux.md b/plans/2026-07-30-supporter-badges-v3-ux.md index 12b75acf0a..221dd1159d 100644 --- a/plans/2026-07-30-supporter-badges-v3-ux.md +++ b/plans/2026-07-30-supporter-badges-v3-ux.md @@ -308,8 +308,9 @@ Rules: - `months` — unused months. - `start` — the date the unused balance starts. Not changed by grants (while `months > 0`); advanced by one month per `consume` and by the lapsed count per `lapse`. +- `anchor` — the start of the current run of months, set only where a lapsed run restarts. Every month boundary of the run is counted from it, so a run beginning 31 Jan reaches 28 Feb and then returns to 31 Mar; counting from the moving `start` would hold it at the 28th for good. -Coverage = `[start, addMonths months start)`. `paidThrough = addMonths months start` — read from the last row alone; not a `badges` column. +Write `monthAfter n = addMonths (m + n) anchor`, where `m` is the whole months from `anchor` to `start`, which always sits on a boundary. Coverage = `[start, monthAfter months)`. `paidThrough = monthAfter months` — read from the last row alone; not a `badges` column. **Row:** @@ -319,6 +320,7 @@ Coverage = `[start, addMonths months start)`. `paidThrough = addMonths months st - `delta` — signed months change - `months` — state: unused months after this row - `start` — state: balance start after this row +- `anchor` — state: the run's anchor after this row - ref — bot-assigned payment ref / `charge_id` (grants) - `created_at` @@ -328,20 +330,19 @@ Append protocol: lock the badge's ledger → read the last row → compute the n ``` advance t: -- time bookkeeping only: one lapse row for the fully elapsed months - k = min months (fullMonthsBetween start t) - -- fullMonthsBetween start t: the largest m >= 0 with addMonths m start <= t - if k > 0: append (lapse, −k, months − k, addMonths k start) -- O11, one row + k = the largest k in [0, months] with monthAfter k <= t + if k > 0: append (lapse, −k, months − k, monthAfter k) -- O11, one row issue t: -- run after advance t - requires months > 0 && start <= t && no issuance for [start, addMonths 1 start) - sign the credential, expiry sundayAfter (addMonths 1 start) - in one transaction: append (consume, −1, months − 1, addMonths 1 start) -- O10 - + issuance row for [start, addMonths 1 start) + requires months > 0 && start <= t && no issuance for [start, monthAfter 1) + sign the credential, expiry sundayAfter (monthAfter 1) + in one transaction: append (consume, −1, months − 1, monthAfter 1) -- O10 + + issuance row for [start, monthAfter 1) on signing failure: no rows; retried at the next `issue` grant t n src: -- O1–O5; t = settlement time, - months == 0 → append (grant src, +n, n, max start t) -- provider period start for O2 - months > 0 → append (grant src, +n, months + n, start) + months == 0 && t > start → append (grant src, +n, n, t), anchor = t -- provider period start for O2 + otherwise → append (grant src, +n, months + n, start) -- same run keeps its anchor debit reason: append (debit reason, −months, 0, start) -- O6–O9 diff --git a/plans/2026-09-01-badges-renewal-alerts.md b/plans/2026-09-01-badges-renewal-alerts.md new file mode 100644 index 0000000000..2934bc3cc6 --- /dev/null +++ b/plans/2026-09-01-badges-renewal-alerts.md @@ -0,0 +1,151 @@ +# Badge renewal and alerts — code-redeemed badges + +Continues `plans/2026-08-27-badges-mvp-streams.md` stream 1. Model `plans/2026-07-30-supporter-badges-v3-ux.md` §2.4, §2.9, §2.11, §3; engine sketch `plans/2026-07-31-badges-core-implementation.md` §6. Protocol `docs/protocol/badges-rpc.{md,schema.json}`. + +Those two older plans predate the code. Where they disagree with what is built, what is built wins — see §7. + +--- + +## 1. What this fixes + +A code says how many months it is worth. Nothing reads it. `getBadgeCode` does not select `months`, and `redeemCode` hardcodes `addMonths 1 now`, so a twelve-month code buys one month and nothing renews it. + +The ledger is the mechanism that fixes it: a redemption credits the months, each issuance debits one, and the client asks for the next one when it comes due. + +**Scope.** Badges redeemed by code. No subscriptions, invoices, store payments, upgrades, transfers or pause — those tables and error paths stay untouched and unwritten. No app UI. + +**Not in scope, deliberately.** Investor badges (the type stays, unused) and lifetime badges (being removed in `stable`, merged here later). Every badge in this slice has a balance and an expiry. + +--- + +## 2. The ledger, minimally + +Three operations, against the tables as built. + +| op | entry_type | change | writes | +|---|---|---|---| +| grant, on redemption | `credit` / `payment` | +N months | one row | +| issue a month | `debit` / `badge` | −1 | one row + one `badge_issuances` row, same transaction | +| lapse elapsed months | `debit` / `lapse` | −k | one row | + +Every row carries the state after it: `balance_months`, `balance_start_ts`, `balance_badge_type`. **The last row is the state** — nothing derives a balance by summing, on either side. + +- coverage is `[balance_start_ts, addMonths balance_months balance_start_ts)` +- `paidThrough` = `addMonths balance_months balance_start_ts`, read from the last row alone +- credential expiry = `endOfSundayAfter (addMonths 1 balance_start_ts)` — **anchored on the balance, not on `now`**, or the schedule drifts a little further from the purchase every month + +`advance t` runs before every grant and issue: lapse the fully elapsed unissued months in one row, if any. Issue requires `balance_months > 0`, `balance_start_ts <= t`, and no issuance already covering `[balance_start_ts, +1 month)` — that last check is what makes a repeated `issueBadge` return the stored credential and write nothing. + +The columns this slice never writes stay NULL: `payment_id`, `charge_id`, `from_purchase_id`, `to_purchase_id`, `was_paused_since`. A code grant has no invoice, which the wire type already allows (`payment {invoiceId?}`, absent for codes) and the storage type does not — see §7. + +`entry_uuid` is the service's identity for a row and the client's replication key. The client stores rows **verbatim** and never authors one; `entry_type_unknown` / `entry_type_value` keep a row a newer service wrote. + +--- + +## 3. Service + +- `getBadgeCode` selects `months` again; redemption grants that many. +- Redemption becomes: `advance` → grant → issue, and the response's `statement` carries the rows it wrote instead of being empty. +- `issueBadge` (`BSCIssueBadge`, already in the protocol) gets its handler: `advance`, then issue if a month is due, then reply. The `balance` the client asserts names its last held entry; the service returns the entries after it, or the whole ledger with an `opening` restatement when it names nothing the service holds. +- A repeat within an issued period replies with the **stored** credential and writes nothing. Not a re-sign: the same period signed twice yields a different signature and churns the client's credential for nothing. +- An exhausted balance is not an error: the reply carries no `credential` and a statement that shows why. +- Signing stays before every write, as in redemption. A signing failure writes nothing and the month is still due. + +--- + +## 4. Client worker + +One worker per user, on the agent `Worker` framework already used for delivery (`getAgentWorker` / `hasWorkToDo'` / `cancelWorker`, and the `TMap _ Worker` fields on `ChatController`). Per user because badge state is per profile and one profile must not stall another. + +The worker holds no queue. A trigger only signals it; each pass reads stored state and derives the work. Signals are therefore free to be lost or duplicated. + +**Pass**, per badge: is a month due, or has one lapsed, or is an alert derivable? If a request is needed, take the user's badge lock, send it signed with the purchase key, apply the response, then release. Applying a response means: copy the statement's entries verbatim, verify the credential, store the issuance, update the purchase, and re-present the badge to contacts. + +**Triggers.** Chat start, chat activate, and redemption. Network restore and profile switch belong here too and cost nothing to add, but no caller sends them in this slice. Redemption has no immediate follow-up work — the round trip returns the credential and the statement, and the redeem command already stores the rows, sets the badge and broadcasts — but scheduling is something only a pass produces, so a purchase created without a signal has no wake-up until the next start or activate. Any later path that creates a purchase signals for the same reason. + +**Scheduling.** No timer thread and no boundary map. Each pass ends by scheduling its own next wake-up, in the shape of `rescheduleWork` (`simplexmq` `NtfSubSupervisor.hs:478`): clear `doWork`, fork a sleeper that signals it at the next boundary, then block as usual. Two boundaries only, both day-granularity — the next month falling due, and `paidThrough`. + +Unlike its original, this sleeper is **tracked per user and replaced**, following `deleteTimedItem` (`Internal.hs:1687`): cancel the previous before forking, cancel with the worker at chat stop, and re-check `waitChatStartedAndActivated` on waking. Badge horizons are a month where NtfSubSupervisor's are minutes, so untracked sleepers would accumulate — one per activate — instead of retiring. + +**Expiry.** When the balance is exhausted and the last period ends, the shown badge is cleared and the profile update broadcast — the removal update of UX 2.11. This is the visible half of "the badge expired". + +**Locking.** Add a `ChatLockEntity` constructor for the badge user and use `withEntityLock`, rather than the separate `badgeLocks` map §6 sketches. Same discipline, one lock map, and the `chatLock` ordering comes for free. + +Nothing here is load-bearing for correctness: a pass derives its work from stored state, so chat start alone gives correct behaviour. It is what lets a client left running renew without a restart, and with `badgeGraceInterval` at 7 days a renewal hours late is invisible to contacts. + +Timeouts are retried as the identical signed envelope at the next signal, never on a poll timer. + +--- + +## 5. Alerts + +One alert: `BASupportEnded`, at `paidThrough` with the balance exhausted, once. + +`BAPrepaidEnding` — the 3-days-out warning — is **not** implemented, because a user cannot act on it. Topping up before the current period ends needs the service to credit a balance without issuing a credential, which is a change to the redemption path that beta does not carry; today a prepaid badge can only be continued after it has elapsed. Record that with a `TODO [badges]` in the alert derivation, where the missing branch is. The other three `BadgeAlertKind` constructors need subscriptions and stay unemitted. + +Derived from state at the end of each pass, not stored as pending: compare the derived alert with `alert_acked_kind` / `alert_acked_episode` on the purchase, emit if they differ. The episode is the value that makes this occurrence distinct — `paidThrough`. Acknowledging writes the pair; snoozing sets `alert_snooze_until`, after which the alert is emitted once more. All three columns already exist. + +--- + +## 6. Command and event surface + +None of this exists — the names in §6 of the older plan have no code behind them. The minimum that makes the slice observable and testable from the terminal: + +- `APIGetBadgeState` → the user's badges, balance, `paidThrough`, and current alert +- `APIAckBadgeAlert` — acknowledge or snooze +- `CEvtBadgeChanged` — state changed, including a renewal that arrived without a command +- `CEvtBadgeAlert` — an alert became derivable + +Each needs its `chatCommandP` parser, `View.hs` rendering, and registration in `bots/src/API/Docs/{Commands,Responses}.hs`, which `tests/APIDocs.hs` enforces. + +--- + +## 7. Types to correct first + +Declared during design, never exercised — `Badges.Types`' `BadgePurchase`, `BadgeIssuance`, `CTPayment` and `CTCharge` have no users, so none of this costs a migration or a call site. (`Simplex.Chat.Badges` has a different `BadgePurchase`, the payment-proof sum, which `Badges.Types` hides; that one is in use and unaffected.) + +- **Add a `code` credit type, on the wire and in storage.** The wire currently models a code grant as `payment` with `invoiceId` absent. Storing it as anything else would mean the client rewriting a row it is meant to replicate verbatim, so both sides gain the constructor together. It also makes the absence structural: a code's own invoice belongs to the buyer and lives in `badge_code_invoices` on the service, and the redeemer's ledger must never reference it. Cheap now, expensive once stream 2 ships. +- `CTPayment.invoiceId` becomes `InvoiceId`, not `Int64` — `invoices.invoice_id` is `TEXT`. +- `CTCharge.chargeId` becomes `Text`, matching `subscription_charges.charge_id` and its wire twin `SCCharge`, corrected in milestone A. Unused in this slice; changed so the twins stop disagreeing. +- `BadgePurchase` gains a funding sum — payment or code — rather than a mandatory `Int64` `paymentId`. Exactly one is set and the schema cannot say so, so the type should. +- `BadgeIssuance` loses its `Maybe` period, expiry and entry id. Lifetime is gone, the columns are `NOT NULL`, and every issuance is written beside exactly one `consume` row. +- `UserBadgeState`'s subscription fields stay: `renewsAt` is `Nothing` and `willRenew` is `False` until subscriptions exist. + +Out of scope but worth knowing: `badge_ledger.payment_id` references a **payment** while the wire's `payment` credit names an **invoice**. Both are NULL for a code grant, so this slice never has to resolve it. + +## 8. Order of work + +**A — ledger on the service.** The injectable clock (§9), reading `months`, the three transitions, redemption grants and issues, the `issueBadge` handler, the statement in both responses. Done when a three-month code redeems and a second `issueBadge` a month later returns a second credential, asserted against the service's own rows. + +**B — client replica.** Store the statement verbatim, read the balance from the last row, resolve §7's types. Done when the client's rows equal the service's row for row after a redemption. + +**C — worker and renewal.** Worker, lock, self-scheduling, the pass, re-presentation. Done when a badge whose month has elapsed renews with no command, and one whose balance is exhausted loses its shown badge and broadcasts the removal. + +**D — alerts and surface.** The ended alert, ack and snooze, the four commands and events. Done when the terminal shows it at `paidThrough` and acknowledging silences it. + +## 9. Testing time + +Nothing about the badge service is mocked: the harness already runs the real one in-process, so a fake would be less faithful and no faster. What is mocked is the clock. + +**An injectable `now`** — `IO UTCTime` in config, defaulting to `getCurrentTime`. No badge code calls the clock directly: the transitions already take `now` as a parameter, and so do their two callers, the service handler and the worker pass. In tests both sides read one source — real time plus a test-controlled offset — so a test can redeem a twelve-month code, jump the offset a month, signal the worker, and assert, twelve times over, in milliseconds and against the real service, real signing and real rows. + +The offset tracks real time rather than freezing it, which is what keeps the sleeper honest: `rescheduleWork` computes `actionTs - now` in shifted time and still sleeps the right real duration. + +This is preferred over making the issuance period configurable. The period is baked into the credential's signed expiry and interacts with the Sunday rounding, so shortening it to seconds means disabling the rounding too — two knobs, and production arithmetic that no test exercises. A clock offset leaves every production computation exactly as shipped and only lies about the date. + +It also reaches what waiting cannot: eight months offline, a boundary on the 31st, a leap day. Those hold the bugs. + +Real elapsed time is then needed for one thing only — that the sleeper wakes the worker at all. With the offset set a second short of a boundary that is a one-second test. + +Tests land with each step, extending `tests/Bots/BadgeServiceTests.hs`. + +## 10. Done means + +- a twelve-month code yields twelve monthly credentials, one per month, and a thirteenth request yields none +- a second request inside an issued month returns the credential already stored, unchanged +- re-issue happens without a command, from the worker's own wake-up alone +- an app offline across several months lapses exactly the elapsed ones and issues the current one +- redeeming the same code twice still yields one badge and one set of ledger rows +- client and service ledgers match row for row, and the client authored none of them +- the ended alert fires once, survives a restart, and stays silent once acknowledged +- an expired badge disappears from contacts' view without the user acting diff --git a/plans/2026-09-07-badge-ledger-types.md b/plans/2026-09-07-badge-ledger-types.md new file mode 100644 index 0000000000..424488ee9e --- /dev/null +++ b/plans/2026-09-07-badge-ledger-types.md @@ -0,0 +1,103 @@ +# Badge ledger: one entry type + +This branch introduced seven types to describe ledger rows. Three hold nothing the wire's own `StatementEntry` does not already carry. Two describe a plan the caller can hold directly. One pairs a balance with a row id used for a single equality test. One holds three timestamps that two consecutive entries already determine. `StatementEntry` becomes the single representation, and nothing replaces the rest. + +| introduced | what it holds beyond `StatementEntry` | +| --- | --- | +| `LedgerBalance` | nothing — its four fields are `StatementEntry`'s | +| `ServiceLedgerEntry` | nothing — `statementEntry` exists only to rename `entryUuid`, flatten the balance, and fill in a `wasPausedSince` the service never sets | +| `LedgerRow` | nothing — a change, a balance and a type, all fields of `StatementEntry` | +| `LedgerPlan` | the rows to write, and which is the issuance | +| `SignedPlan` | the same, with the credential | +| `LedgerTip` | a numeric row id beside a balance | +| `BadgePeriod` | three timestamps, all derivable from two consecutive entries | + +## The entry is the state + +The ledger's central property is that the last row *is* the balance. `StatementEntry` already carries it — `balanceMonths`, `balanceStartTs`, `balanceAnchorTs`, `balanceBadgeType` — alongside the change that produced it and the type of operation that made it. So an operation is a function from the last entry to the next. The three keep the shapes they have today, with `LedgerBalance` replaced by `StatementEntry`, a uuid added, and the credit type moving in from `ledgerPlan` because a grant now writes its own entry type: + +```haskell +lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was advanceBalance +issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry -- was issueMonth +grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry -- was grantMonths +``` + +Each sets its own `changeMonths` and `entryType` — `SEDebit SDLapse`, `SEDebit SDBadge`, the caller's credit — so the count and the type it is recorded under travel together and cannot be mismatched. `Nothing` means the operation does not apply: nothing has elapsed, or no month is due. `grantEntry` is total, because a grant always applies. + +The arithmetic inside them does not change. `entryId` and `createdAt` are supplied and never read, and `wasPausedSince` stays `Nothing` as the service sets it today. The price is that uuid generation moves from the store to the caller — which is also what lets an entry be identified before it is written, and be the value the credential is stored against. + +The client is already here: `getBadgeLedgerLastEntry` returns a `StatementEntry`, because that is what the wire sends and what `badge_ledger` stores. This makes the service match rather than inventing a shape of its own. + +## No plan, just composition + +`ledgerPlan` goes too. A request lapses first, as its own entry, and the caller chains the rest, keeping whichever came back: + +```haskell +let lapsed = lapseEntry now uuid1 tip + current = fromMaybe tip lapsed + issued = issueEntry now uuid2 current + rows = catMaybes [lapsed, issued] +``` + +A redemption is the same chain with a grant between the two: `lapseEntry`, `grantEntry`, `issueEntry`. It grants the months and issues the first of them at once, which is what `ledgerPlan` does today. + +Do not skip the lapse before a grant. `grantEntry` restarts the run only when the balance is empty, so elapsed months still on the books get added to instead: three months bought on 10 January and another code redeemed on 10 June gives five months running from January, every one already spent, where lapsing first gives two running from June. + +The caller generates one uuid per entry it might write. These two are the only callers, so a function to compose them would serve two sites that differ by one step. + +This disposes of `LedgerPlan` and `SignedPlan` without replacing them: the rows are a list the caller already holds, and the credential belongs to `issued`, a local in scope rather than something to find by position or by type. + +Two store functions simplify with them. `getLedgerEntries` returns `[StatementEntry]`, so `credentialResponse`'s `map statementEntry` disappears. And `appendLedgerPlan` loses both its `TVar ChaChaDRG` and its `now`, since the entries now carry their own ids and timestamps — it gains only the issuance's predecessor, which the caller has as `current`. + +## The period is derived + +`BadgePeriod` carried what an issuance writes to `badge_issuances`. Given the issuance entry and the one before it, all three values are already there: + +- `periodStart` is the previous entry's `balanceStartTs` +- `periodEnd` is the issuance entry's own `balanceStartTs`, because issuing moves the start to the period end +- the expiry is a pure function of `periodEnd` + +The client already derives the two bounds this way in `getIssuedPeriod`, and takes the expiry from the credential, which is the service's own computation arriving back. On the service, compute the expiry at both points it is needed — signing, then writing the issuance row — from the same entry through the same function. + +The predecessor is not always one of the written rows — when nothing lapsed, the entry before the issue is the tip. Composing at the call site names it anyway: it is `current`, the value the issue was computed from, so the writer has it without having to look for it. + +## The tip is just an entry + +`LedgerTip` is the newest row's id and balance. The id has one use — checking that no row was written while the plan was being signed — and that only asks whether it is still the same row, which `StatementEntry`'s uuid answers. So `getLedgerTip` returns a `StatementEntry`. + +Reading a row as an entry means decoding its type, which `entryTypeFromColumns` does only partially — but totally over `code`, `badge` and `lapse`, which is everything the service writes. So `Nothing` still means no rows. + +## What stays shared + +The client authors no entries — it stores what the service sends and reads the last one back. So `lapseEntry`, `issueEntry` and `grantEntry` are the service's alone after this change, though `Ledger.hs` stays one module and the client simply imports less of it. + +The rest of the module changes only in its argument: `paidThrough`, `elapsedMonths`, `monthsFromAnchor` and `monthAfter` take a `StatementEntry` where they took a `LedgerBalance`. `addMonths`, the tag functions and the column helpers are untouched; the week-boundary function is shifted a day and renamed by the worker plan, not here. `elapsedMonths` need no longer be exported, since `lapseEntry` is the only caller. The client keeps `paidThrough` and gains the check below. + +The client's remaining use, `ledgerPlan` inside `badgeWorkDue`, is removed by the worker plan. + +## Verifying what the service sends + +The client authors nothing, so it takes the service's arithmetic on trust — while holding everything needed to check it. + +**Where.** In `applyBadgeStatement`, as the rows are stored. That is the one place holding both the arriving entries and the stored tip they follow, and it already runs in one transaction. + +**What.** One outcome per entry, from one of two rules: + +- *Against its predecessor* — the previous entry in the statement, or the stored tip for the first one, whatever `previousEntryId` claims. `balanceMonths` equals the predecessor's plus `changeMonths`; a debit moves `balanceStartTs` forward by exactly the months it consumed, counted from the anchor; a credit either leaves the start alone or restarts the run with `balanceStartTs` and `balanceAnchorTs` equal. +- *Opening*, when there is no predecessor at all: a credit whose `balanceMonths` equals its `changeMonths`, with start and anchor equal. + +Checking the first entry against what the client actually holds is also what catches a statement that follows some other ledger — it fails its arithmetic — so `previousEntryId` needs no separate outcome. + +**What happens when it fails: store the row and mark it.** Not refuse. Perks do not depend on the ledger — the credential is signed independently and a receiver verifies that signature — so rejecting a statement would strand a badge the service considers paid while proving nothing. The ledger is the user's record of what was spent, and the useful response to arithmetic that does not add up is to keep it and be able to point at the line. + +**The column.** `balance_checked`, per entry — `1` when the entry follows from its predecessor, `0` when it does not, and null when nobody has looked. Nullable, because "can be checked" and "has been checked" are different things: every row has a predecessor to check against, and none has been checked while the check is a stub. Not `verified`, which already means signature verification on profiles and would read as the same thing. + +The check belongs in `Ledger.hs`, beside the arithmetic it verifies — `monthsFromAnchor` is internal there and would otherwise have to be exported to check a debit's start. Stub it to null until it exists; adding the column now is what keeps it out of a migration of its own. + +## The tests move with the types + +The eight ledger-transition tests in `BadgeTests.hs` and their helpers move to `StatementEntry`. Assertions on `balanceMonths`, `balanceStartTs` and `paidThrough` survive as they are. The ones on periods do not: `pass` no longer returns a `BadgePeriod`, so `testTwelveMonths` and `testLapseAfterGap` state their period bounds as consecutive `balanceStartTs` values instead. + +## Not in scope + +The wire format does not change: `StatementEntry` is what the service already sends and the client already stores, and every column `badge_ledger` has today is still written. The only schema change is `balance_checked`, on the client's `badge_ledger` alone — the service has nothing to check, since it is the side that computes. diff --git a/plans/2026-09-07-badge-worker-simplification.md b/plans/2026-09-07-badge-worker-simplification.md new file mode 100644 index 0000000000..d4de387489 --- /dev/null +++ b/plans/2026-09-07-badge-worker-simplification.md @@ -0,0 +1,168 @@ +# Badge worker: one thread, one sleep + +Today each user has two threads: a worker, and a sleeper that outlives each pass and signals it. That is where the `Weak ThreadId`, the swap-and-kill in `scheduleBadgeWake`, and the sleeper half of `stopBadgeWorkers` come from. The next wake is the earliest of four separately-derived times — a retry after a failed request, the snooze expiry, the stall floor, and the ledger's next renewal — and behind the first sit a per-purchase `(elapsed, delay)` map and three functions to advance it. + +This assumes one badge per user. Most of what collapses here collapses because the maps keyed by purchase have a single key, and because `updateUserBadges` and `updateBadgePurchase` become one function, `updateUserBadge`. + +```haskell +data BadgeWorker = BadgeWorker + { badgeWorkerAsync :: Async (), + badgeWork :: TMVar () + } + +badgeWorkers :: TMap UserId (SessionVar BadgeWorker) +badgeSeq :: TVar Int +``` + +The loop, which the rest of this fills in: + +```haskell +runBadgeWorker :: User -> TMVar () -> CM () +runBadgeWorker user badgeWork = do + emitted <- newTVarIO Nothing + ri <- asks $ badgeRetryInterval . config + forever $ do + at_ <- withRetryInterval ri $ \_ loop -> do + now <- badgeNow + let done = pure $ Just $ badgeStalledInterval `addUTCTime` now + updateUserBadge user emitted now `catchAllErrors` retryBadgeError loop done + now <- badgeNow + liftIO $ waitBadgeWake badgeWork now at_ +``` + +The clock is read twice because a retry sequence can run for hours, so the wait needs a fresh reading rather than the one the pass started from. + +## Waiting + +`registerDelay` makes the deadline an STM value, so the wait is a single transaction over the timer and the signal — no second thread, and the transaction reports which of the two woke it: + +```haskell +waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO () +waitBadgeWake badgeWork now = \case + Nothing -> atomically $ takeTMVar badgeWork + Just at -> waitFor $ diffToMicroseconds $ diffUTCTime at now + where + waitFor time + | time <= 0 = pure () + | otherwise = do + let maxWait = min time $ fromIntegral (maxBound :: Int) + timer <- registerDelay $ fromIntegral maxWait + signalled <- atomically $ do + w <- tryTakeTMVar badgeWork + fired <- readTVar timer + unless (isJust w || fired) retry + pure $ isJust w + unless signalled $ waitFor $ time - maxWait +``` + +This is `threadDelay'` with an escape hatch, and it keeps that function's names so it reads as one. Nothing survives the wait, so there is no handle to keep and nothing to kill. `Nothing` means sleep until signalled. + +`registerDelay` takes an `Int`, so the loop is needed for the same reason `threadDelay'` has one: `maxBound :: Int` is ~292,000 years on 64-bit and ~36 minutes on 32-bit. A month is one sleep everywhere except legacy armv7a, which re-arms — as it already does today. Counting down the remaining time rather than re-reading the clock keeps this correct when a test has shifted `badgeCurrentTime`. + +Taking the signal and reading the timer in one transaction means a signal is never consumed and discarded. + +## Retrying + +`withRetryInterval` holds the backoff in its own recursion — no map, no `TVar`, nothing persisted: + +```haskell +retryBadgeError :: CM a -> CM a -> ChatError -> CM a +retryBadgeError loop done e = eToView e >> if badgeErrorRetry e then loop else done +``` + +Same shape as `retryOnError` (`FileTransfer/Agent.hs:260`), except that it classifies with `badgeErrorRetry` rather than `temporaryOrHostError`, which does not cover `AGENT (A_SERVICE ASETimeout)` — the likeliest renewal failure, and using it would leave the retry inert for the common case. + +`updateUserBadge` returns the next wake, as `updateBadgePurchase` does today. Retry-or-stop is a property of the error rather than a return value, so no result type is needed and `BadgeRetry` collapses to `badgeErrorRetry :: ChatError -> Bool`. + +A service error is the exception, since it is returned rather than thrown: `requestBadgeIssue` becomes `Either (Maybe UTCTime) LedgerBalance`, where `Left` means do not retry and wake then. That is where a `retryAfter` hint lands — `withRetryInterval` owns the sleeping, so a wait the service names becomes a wake time — and where a terminal code lands as the stall floor. Floor the hint at `initialInterval`, since a service answering `0` would otherwise spin the worker, but do not cap it: a service that names a long wait is denying issuance, which it can already do by refusing, and `paidThrough` remains a wake candidate regardless of what it says. + +**The stall floor is a day.** A terminal failure is not retried, and the ledger boundary is a whole term away. The client's own state cannot change without the service, so the only thing that can make the next attempt succeed is the service being repaired — a day is slow enough not to press a service already failing, and fast enough to recover well inside the badge's 8-14 days of headroom. + +**The loop cannot die.** Catching everything except cancellation means no exception ends it, and a failure that reaches the top returns the stall floor as its wake, so a persistent fault is one attempt a day rather than a hot loop. That replaces the `Worker`'s rate-limited restart. + +## When renewal is due + +Renewal is driven by the **credential's expiry**, not the ledger's period end. A badge whose period has ended but whose credential is still valid needs nothing done — the holder keeps their perks, and the service is not asked early. + +The expiry rounding moves one day later, from the Monday after the period to the Tuesday: `endOfSundayAfter`'s `8 - dayOfWeek` becomes `9 - dayOfWeek`. Periods ending anywhere in one Monday-to-Sunday week still share a single expiry, so the anonymity set is unchanged; it now falls on a weekday in every timezone, where Monday 00:00 UTC is Sunday evening in the Americas and puts a failed renewal on a weekend for support. The function is renamed for the day it now returns, and the range in `testSundayExpiry` widens from 1-7 days to 2-8. + +Renewal splits into two steps, normally a day apart: + +- **Request**, on the Monday — while the held credential is still valid, so a failure has a day of slack before anything is visible. +- **Present**, on the Tuesday — as the old credential lapses, so the profile broadcast does not correlate with the request that produced it. + +Neither needs a marker. Both derive from the shown credential's expiry, which the profile already stores, and from whether a newer issuance exists, which `presentIssuedBadge` already compares: + +- **Request** when the shown credential expires within a day, months remain, and the newest issuance is still the one shown — so nothing has been requested yet. A successful request moves the newest expiry a month out, and the condition stops holding by itself. +- **Present** when the newest issuance differs from what is shown and the shown credential has expired — or when nothing is shown at all, which is the state a crash between the issuance write and the profile write leaves behind, and which `testPresentationCatchesUp` covers. + +Ordering falls out of that: presenting cannot precede requesting, because nothing differs until the request succeeds. + +`badgeBoundary` becomes the next of three moments: `shownExpiry - 1 day`, `shownExpiry`, and `paidThrough`. The ledger's `balanceStartTs` goes, since renewal is no longer month-aligned — but `paidThrough` stays, and for a different reason from the other two. The credential's expiry window is what covers renewal: the client renews around it to join the anonymity set, and the recipients' grace period keeps the badge honoured while that happens. `paidThrough` is when entitlement itself ends. The worker has to be there for it, to retire the badge and raise the alert that tells the user to buy again; waiting for the credential to expire would leave them wearing a badge they have stopped paying for. + +Missing a week is safe. A worker whose first run is the Wednesday finds both conditions true and does both in one pass: that renewal loses its anonymity benefit, and nothing else changes, which is the same property every other wake here has. + +## The pass + +```haskell +updateUserBadge :: User -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) +updateUserBadge user emitted now = do + (p, balance) <- ... + retired <- retireExpiredBadge user p now balance + balance' <- + if retired + then pure balance + else do + b <- if requestDue p balance now then requestBadgeIssue ... else pure balance + when (presentDue p now) $ presentIssuedBadge user p now + pure b + emitBadgeAlert user emitted p now balance' + pure $ earliestTime [badgeBoundary now p balance', snoozeAt p now] +``` + +Retiring ends the renewal half of the pass. It means `paidThrough <= now`, so every funded month has already passed and a request could only write lapse rows — it cannot issue, because `advanceBalance` consumes the balance before `issueMonth` sees it. Reconciling the ledger for a badge that is over is not worth a round trip; a later redemption reconciles it anyway by asserting the last entry. + +The guard is on `retired` rather than on the purchase's `shown` field because `p` was read before retirement and its `shown` is stale within the pass. `presentIssuedBadge` has its own `not shown` check, which covers later passes but not this one. + +The alert stays outside the guard: support-ended fires exactly when the balance is exhausted, which is the pass that retires. + +Retirement comes first because it needs no service and reads only stored state, and because an unbounded retry does not return while a failure lasts — anything after the request is unreachable meanwhile. Moving it ahead of the request is behaviour-preserving: `paidThrough` is invariant under issue and lapse, only a grant moves it, and `BSCIssueBadge` never produces one, so it gives the same answer either side. + +## Starting and stopping + +Chat start, `/_app activate`, `APIGetBadgeState` and a redemption can each ask for the worker at the same moment. Exactly one thread must be started, and every caller must come away holding it. + +`getAgentWorker'` manages that by doing the lookup and the create in a single STM transaction, which works only because creating a `Worker` allocates a few TVars and nothing else. Starting a thread is IO and cannot happen inside a transaction, so for us the lookup and the create come apart, and two callers can both find nothing. + +`SessionVar` closes the gap by putting a `TMVar` in the map instead of the value — the map holds the promise of a worker rather than a worker. `getSessVar` either inserts an empty one and returns `Left`, meaning you are the creator, or finds an existing one and returns `Right`. Exactly one caller gets `Left`. + +```haskell +withGetSessVar' badgeSeq userId badgeWorkers now startWorker signalExisting + where + startWorker v = do + badgeWork <- newTMVarIO () -- full: a new worker has work to do + a <- async $ runBadgeWorker user badgeWork + let w = BadgeWorker {badgeWorkerAsync = a, badgeWork} + w <$ atomically (putTMVar (sessionVar v) w) + signalExisting v = do + w <- atomically $ readTMVar $ sessionVar v + w <$ atomically (void $ tryPutTMVar (badgeWork w) ()) +``` + +`readTMVar` blocks the other callers until the creator fills the var, so they signal the one worker rather than starting a second. The hazard is the creator dying between those two steps, leaving an empty var everyone waits on forever; `withGetSessVar'` wraps the creating branch in `bracketOnError` and drops it from the map so the next caller creates a fresh one. + +Shutdown follows `closeAgentClient`, which stops a `TMap k (SessionVar (Async ()))` the same way: swap the map out, then for each var fork a thread that waits on `readTMVar` and `uninterruptibleCancel`s what it finds. Waiting rather than skipping is what catches a worker created after the swap; the fork is so shutdown does not block on it. + +## What goes away + +| removed | replaced by | +| --- | --- | +| `Worker`, `getAgentWorker'`, `cancelWorker`, restart accounting | `Async`, `badgeWork`, `SessionVar` | +| `scheduleBadgeWake`, `Weak ThreadId`, `killWeakThread`, sleeper cleanup | `waitBadgeWake` | +| `BadgeAttempt`, `nextBadgeAttempt`, `badgeAttemptDelay` | `withRetryInterval`'s own recursion | +| `BadgeMemory` and its two maps | one `TVar (Maybe BadgeOccurrence)`, once a user has one badge | +| `BadgeRetry`'s two constructors | `badgeErrorRetry :: ChatError -> Bool` | + +Wake candidates go from four to three. The one that goes is the retry, because `withRetryInterval` sleeps between attempts rather than returning a time; the next request or present day, the snooze expiry, and the stall floor — or a wait the service named — all remain. + +Unchanged: everything derived from stored state so a wake early, late or missed changes only timing; the alert-occurrence memory; `badgeCurrentTime`; `RetryInterval` in config, both as the existing pattern and so a test can shorten it. Signalling `badgeWork` replaces `startBadgeWork` at the same call sites. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 8724cadca6..b64971cc52 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -41,6 +41,7 @@ library Simplex.Chat.Badges Simplex.Chat.Badges.CLI Simplex.Chat.Badges.Code + Simplex.Chat.Badges.Ledger Simplex.Chat.Badges.Service Simplex.Chat.Badges.Types Simplex.Chat.Names @@ -431,6 +432,7 @@ executable simplex-badge-service aeson ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 + , containers ==0.6.* , crypton ==0.34.* , directory ==1.3.* , mtl >=2.3.1 && <3.0 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d6aa9cb283..eeb55199f3 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -69,6 +69,8 @@ defaultChatConfig = chatVRange = supportedChatVRange, badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys, badgeServiceAddress = Nothing, + badgeCurrentTime = getCurrentTime, + badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000}, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options @@ -189,6 +191,8 @@ newChatController deliveryTaskWorkers <- TM.emptyIO deliveryJobWorkers <- TM.emptyIO relayRequestWorkers <- TM.emptyIO + badgeWorkers <- TM.emptyIO + badgeSeq <- newTVarIO 0 relayGroupLinkChecksAsync <- newTVarIO Nothing webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState chatRelayTests <- TM.emptyIO @@ -235,6 +239,8 @@ newChatController deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, + badgeWorkers, + badgeSeq, relayGroupLinkChecksAsync, webPreviewState, chatRelayTests, diff --git a/src/Simplex/Chat/Badges/Code.hs b/src/Simplex/Chat/Badges/Code.hs index cff8812b80..7e2c380119 100644 --- a/src/Simplex/Chat/Badges/Code.hs +++ b/src/Simplex/Chat/Badges/Code.hs @@ -3,11 +3,11 @@ -- | Badge redemption codes, shared by the client, the badge service and the checkout site. -- --- A code is @SXB-@ and 20 Crockford base32 characters in four groups of five: +-- A code is "SXB-" and 20 Crockford base32 characters in four groups of five: -- 19 payload characters and a final check character. -- -- Reading folds the characters the alphabet omits so that a code copied by hand still --- verifies: it is case-insensitive and maps @I@ and @L@ to @1@ and @O@ to @0@. +-- verifies: it is case-insensitive and maps 'I' and 'L' to '1' and 'O' to '0'. -- -- The check character is Luhn mod N with N = 32 over the payload values, which keeps it -- inside the same 32-character alphabet. It detects every single-character substitution diff --git a/src/Simplex/Chat/Badges/Ledger.hs b/src/Simplex/Chat/Badges/Ledger.hs new file mode 100644 index 0000000000..808f8cb5e3 --- /dev/null +++ b/src/Simplex/Chat/Badges/Ledger.hs @@ -0,0 +1,176 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Badges.Ledger + ( emptyEntry, + lapseEntry, + grantEntry, + issueEntry, + paidThrough, + balanceChecked, + addMonths, + endOfMondayAfter, + entryTypeColumns, + entryTypeFromColumns, + creditTypeTag, + debitTypeTag, + ) +where + +import Data.Text (Text) +import Data.Time.Calendar (addDays, addGregorianMonthsClip, toGregorian) +import Data.Time.Calendar.WeekDate (toWeekDate) +import Data.Time.Clock (UTCTime (..)) +import Simplex.Chat.Badges (BadgeType) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) + +-- | balanceStartTs is always a whole number of months from the anchor; this is that number. +-- The calendar difference overshoots by at most one month, so one comparison settles it. +monthsFromAnchor :: StatementEntry -> Integer +monthsFromAnchor StatementEntry {balanceStartTs, balanceAnchorTs} + | addMonths months balanceAnchorTs <= balanceStartTs = max 0 months + | otherwise = max 0 (months - 1) + where + (ay, am, _) = toGregorian (utctDay balanceAnchorTs) + (sy, sm, _) = toGregorian (utctDay balanceStartTs) + months = (sy - ay) * 12 + toInteger (sm - am) + +-- | The start of the month that follows n more months of this run. +monthAfter :: StatementEntry -> Int -> UTCTime +monthAfter e n = addMonths (monthsFromAnchor e + toInteger n) (balanceAnchorTs e) + +paidThrough :: StatementEntry -> UTCTime +paidThrough e = monthAfter e (balanceMonths e) + +elapsedMonths :: UTCTime -> StatementEntry -> Int +elapsedMonths t e = length $ takeWhile (\m -> monthAfter e m <= t) [1 .. balanceMonths e] + +-- | The seed for a purchase with no ledger yet: no months, and a run starting now. +emptyEntry :: UTCTime -> BadgeType -> StatementEntry +emptyEntry t badgeType = + StatementEntry + { -- this entry is never stored, and every operation puts its own id on the entry it returns + entryId = "", + changeMonths = 0, + balanceMonths = 0, + balanceStartTs = t, + balanceAnchorTs = t, + balanceBadgeType = badgeType, + wasPausedSince = Nothing, + createdAt = t, + entryType = SECredit SCOpening + } + +-- | Writes off the months that have passed. +lapseEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry +lapseEntry t entryId e@StatementEntry {balanceMonths} + | k == 0 = Nothing + | otherwise = + Just + e + { entryId, + createdAt = t, + changeMonths = negate k, + balanceMonths = balanceMonths - k, + balanceStartTs = monthAfter e k, + entryType = SEDebit SDLapse + } + where + k = elapsedMonths t e + +-- | New months start where the current coverage ends, or at t if it has already lapsed - so they +-- are neither spent on the month still running nor backdated over a gap. +grantEntry :: UTCTime -> Text -> Int -> StatementCreditType -> StatementEntry -> StatementEntry +grantEntry t entryId n credit e@StatementEntry {balanceMonths, balanceStartTs} + -- only a lapsed run restarts; topping up before coverage ends continues the run on its anchor, + -- so buying a month at a time keeps the same day of month as buying a year at once + | lapsed = credited {balanceMonths = n, balanceStartTs = t, balanceAnchorTs = t} + | otherwise = credited {balanceMonths = balanceMonths + n} + where + lapsed = balanceMonths == 0 && t > balanceStartTs + credited = e {entryId, createdAt = t, changeMonths = n, entryType = SECredit credit} + +-- | The period issued runs from the previous entry's balanceStartTs to this one's. +issueEntry :: UTCTime -> Text -> StatementEntry -> Maybe StatementEntry +issueEntry t entryId e@StatementEntry {balanceMonths, balanceStartTs} + | balanceMonths <= 0 || balanceStartTs > t = Nothing + | otherwise = + Just + e + { entryId, + createdAt = t, + changeMonths = -1, + balanceMonths = balanceMonths - 1, + balanceStartTs = monthAfter e 1, + entryType = SEDebit SDBadge + } + +-- | Pairs each arriving entry with whether its balance follows from the one before it, the stored +-- tip standing in for the first one's predecessor. 'Nothing' is "not checked". +-- TODO [badges] do the arithmetic. +balanceChecked :: Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)] +balanceChecked _tip = map (\e -> (e, Nothing)) + +-- | The tag stored is the string the service sent, so a type this version does not know is kept +-- as received and can be read once it does. +entryTypeColumns :: StatementEntryType -> (Text, Maybe Text, Maybe Text) +entryTypeColumns = \case + SECredit c -> ("credit", Just $ creditTypeTag c, Nothing) + SEDebit d -> ("debit", Nothing, Just $ debitTypeTag d) + +creditTypeTag :: StatementCreditType -> Text +creditTypeTag = \case + SCPayment _ -> "payment" + SCCode -> "code" + SCCharge _ -> "charge" + SCSupport -> "support" + SCTransferIn _ -> "transferIn" + SCOpening -> "opening" + SCUnknown {tag} -> tag + +debitTypeTag :: StatementDebitType -> Text +debitTypeTag = \case + SDRefund -> "refund" + SDUpgrade _ -> "upgrade" + SDTransferOut _ -> "transferOut" + SDSupport -> "support" + SDBadge -> "badge" + SDLapse -> "lapse" + SDUnknown {tag} -> tag + +-- | Only the types a tag alone rebuilds, which is those whose constructor has no fields; the rest +-- answer Nothing rather than a type with an invented payload. The client also stores each type's +-- JSON and reads that first, so this is its fallback; the service has no such column. +-- TODO [badges] take the reference columns and rebuild payment, charge, transferIn, upgrade and +-- transferOut, without which the service cannot re-emit a statement carrying one. +entryTypeFromColumns :: Text -> Maybe Text -> Maybe Text -> Maybe StatementEntryType +entryTypeFromColumns entryType credit_ debit_ = case (entryType, credit_, debit_) of + ("credit", Just t, _) -> SECredit <$> creditType t + ("debit", _, Just t) -> SEDebit <$> debitType t + _ -> Nothing + where + creditType = \case + "code" -> Just SCCode + "support" -> Just SCSupport + "opening" -> Just SCOpening + _ -> Nothing + debitType = \case + "badge" -> Just SDBadge + "lapse" -> Just SDLapse + "refund" -> Just SDRefund + "support" -> Just SDSupport + _ -> Nothing + +addMonths :: Integer -> UTCTime -> UTCTime +addMonths n (UTCTime d t) = UTCTime (addGregorianMonthsClip n d) t + +-- Every badge in a week expires together, revealing nothing about when it was bought. +-- The end of a Monday is the next Tuesday at 00:00, so this returns a Tuesday and 9 is right. +-- Returning a Monday instead would put the expiry on Sunday evening in the Americas, leaving a +-- renewal that failed there waiting for weekend support. +endOfMondayAfter :: UTCTime -> UTCTime +endOfMondayAfter (UTCTime d _) = + let (_, _, dayOfWeek) = toWeekDate d -- 1 Monday .. 7 Sunday + in UTCTime (addDays (toInteger (9 - dayOfWeek)) d) 0 diff --git a/src/Simplex/Chat/Badges/Service.hs b/src/Simplex/Chat/Badges/Service.hs index 7de198fec9..d876dbb059 100644 --- a/src/Simplex/Chat/Badges/Service.hs +++ b/src/Simplex/Chat/Badges/Service.hs @@ -98,8 +98,7 @@ data BadgeServiceCommand balance :: BadgeBalance } | BSCIssueBadge - { badgeRequest :: BadgeRequest, - balance :: BadgeBalance + { balance :: BadgeBalance -- no badgeRequest: the service holds the key, the tier and the expiry } | BSCPauseBadge @@ -173,6 +172,9 @@ data StatementEntry = StatementEntry changeMonths :: Int, balanceMonths :: Int, balanceStartTs :: UTCTime, + -- the start of the current run of months; every month boundary in it is counted from here, + -- so that the day of month survives a short month + balanceAnchorTs :: UTCTime, balanceBadgeType :: BadgeType, wasPausedSince :: Maybe UTCTime, createdAt :: UTCTime, @@ -184,7 +186,8 @@ data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {de deriving (Show) data StatementCreditType - = SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments + = SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store payments + | SCCode -- a redeemed code; its own invoice belongs to the buyer, not to the redeemer | SCCharge {chargeId :: Text} | SCSupport | SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519} diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 31516dbdfe..cbd2ecb1b6 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -18,12 +18,13 @@ module Simplex.Chat.Badges.Types LedgerCreditType (..), LedgerDebitType (..), BadgeAlertKind (..), + BadgeFunding (..), BadgePurchase (..), BadgeLedgerEntry (..), BadgeCharge (..), BadgeIssuance (..), BadgeAlert (..), - UserBadgeState (..), + BadgeState (..), ) where import Data.Aeson (FromJSON, ToJSON) @@ -34,12 +35,12 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment) +import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, enumJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, taggedObjectJSON) #if defined(dbPostgres) import Database.PostgreSQL.Simple.FromField (FromField (..)) import Database.PostgreSQL.Simple.ToField (ToField (..)) @@ -84,8 +85,9 @@ data LedgerEntryType = LECredit {credit :: LedgerCreditType} | LEDebit {debit :: -- confirmed data LedgerCreditType - = CTPayment {invoiceId :: Int64} - | CTCharge {chargeId :: Int64} + = CTPayment {invoiceId :: InvoiceId} + | CTCode + | CTCharge {chargeId :: Text} | CTSupport | CTTransferIn {fromPurchaseId :: Maybe Int64} | CTOpening @@ -107,6 +109,31 @@ data LedgerDebitType data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded deriving (Eq, Show) +instance TextEncoding BadgeAlertKind where + textEncode = \case + BARenewalApproaching -> "renewal_approaching" + BAPaymentIssue -> "payment_issue" + BASubscriptionEnded -> "subscription_ended" + BAPrepaidEnding -> "prepaid_ending" + BASupportEnded -> "support_ended" + textDecode = \case + "renewal_approaching" -> Just BARenewalApproaching + "payment_issue" -> Just BAPaymentIssue + "subscription_ended" -> Just BASubscriptionEnded + "prepaid_ending" -> Just BAPrepaidEnding + "support_ended" -> Just BASupportEnded + _ -> Nothing + +instance FromField BadgeAlertKind where fromField = fromTextField_ textDecode + +instance ToField BadgeAlertKind where toField = toField . textEncode + +-- exactly one of these funds a purchase; the schema cannot say so, both columns being nullable +data BadgeFunding + = BFPayment {paymentId :: PaymentId} + | BFCodeRedemption {redemptionId :: Int64} + deriving (Eq, Show) + -- to review data BadgePurchase = BadgePurchase { badgePurchaseId :: Int64, @@ -117,7 +144,7 @@ data BadgePurchase = BadgePurchase badgeType :: BadgeType, priceId :: Maybe BadgePriceId, offerId :: Maybe BadgeOfferId, - paymentId :: Int64, + funding :: BadgeFunding, status :: BadgePurchaseStatus, credential :: Maybe BadgeCredential, alertAcked :: Maybe (BadgeAlertKind, Text), @@ -134,6 +161,7 @@ data BadgeLedgerEntry = BadgeLedgerEntry changeMonths :: Int, balanceMonths :: Int, balanceStartTs :: UTCTime, + balanceAnchorTs :: UTCTime, balanceBadgeType :: BadgeType, wasPausedSince :: Maybe UTCTime, serviceCreatedAt :: UTCTime, @@ -156,14 +184,16 @@ data BadgeCharge = BadgeCharge } deriving (Show) --- unconfirmed draft +-- every issuance covers one month and is written beside exactly one debit(badge) row data BadgeIssuance = BadgeIssuance - { issuanceId :: Int64, + { issuanceId :: Text, badgePurchaseId :: Int64, - periodStart :: Maybe UTCTime, - periodEnd :: Maybe UTCTime, - expiry :: Maybe UTCTime, - entryId :: Maybe Int64, + badgeType :: BadgeType, + periodStart :: UTCTime, + periodEnd :: UTCTime, + expiry :: UTCTime, + entryId :: Int64, + credential :: BadgeCredential, createdAt :: UTCTime } deriving (Show) @@ -177,17 +207,19 @@ data BadgeAlert = BadgeAlert } deriving (Show) --- unconfirmed draft -data UserBadgeState = UserBadgeState - { badges :: [BadgePurchase], - shownBadgeId :: Maybe Int64, - payments :: [StoredPayment], +-- | The user's badge as the badge surfaces render it. The purchase keys are deliberately absent: +-- this travels to the UI and over remote control, and they are secrets that stay in core. +data BadgeState = BadgeState + { badgePurchaseId :: Int64, + badgeType :: BadgeType, monthsLeft :: Int, - paidThrough :: Maybe UTCTime, + paidThrough :: UTCTime, + -- payments returns here with the payment types, which this slice neither writes nor encodes renewsAt :: Maybe UTCTime, willRenew :: Bool, alert :: Maybe BadgeAlert } + deriving (Show) instance TextEncoding BadgePurchaseStatus where textEncode = \case @@ -224,3 +256,14 @@ instance ToField BadgeCodePaymentStatus where toField = toField . textEncode $(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) + +instance ToJSON BadgeAlertKind where + toJSON = textToJSON + toEncoding = textToEncoding + +instance FromJSON BadgeAlertKind where + parseJSON = textParseJSON "BadgeAlertKind" + +$(JQ.deriveJSON defaultJSON ''BadgeAlert) + +$(JQ.deriveJSON defaultJSON ''BadgeState) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index bc46533b29..c127ff8ff3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,6 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, LocalBadge) +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -92,6 +93,7 @@ import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), MsgId, NMsgMeta (..), NtfServer, ProtocolType (..), QueueId, SMPMsgMeta (..), SubscriptionMode (..), XFTPServer) +import Simplex.Messaging.Session (SessionVar) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) @@ -145,6 +147,10 @@ data ChatConfig = ChatConfig badgePublicKeys :: Map Int BBSPublicKey, -- Nothing until the badge service is deployed badgeServiceAddress :: Maybe (ConnectTarget 'CMContact), + -- the only clock badge code reads, so tests can shift it; production arithmetic is unchanged + badgeCurrentTime :: IO UTCTime, + -- how long a badge worker waits before repeating a renewal that failed for a passing reason + badgeRetryInterval :: RetryInterval, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -278,6 +284,12 @@ defaultInlineFilesConfig = data ChatDatabase = ChatDatabase {chatStore :: DBStore, agentStore :: DBStore} +-- | Signalling badgeWork wakes the worker from its own wait; a full var means it has work to do. +data BadgeWorker = BadgeWorker + { badgeWorkerAsync :: Async (), + badgeWork :: TMVar () + } + data ChatController = ChatController { currentUser :: TVar (Maybe User), randomPresetServers :: NonEmpty PresetOperator, @@ -310,6 +322,9 @@ data ChatController = ChatController deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework + -- one badge worker per user: badge state is per profile, and one profile must not stall another + badgeWorkers :: TMap UserId (SessionVar BadgeWorker), + badgeSeq :: TVar Int, relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), webPreviewState :: Maybe WebPreviewState, chatRelayTests :: TMap ConnId RelayTest, @@ -641,6 +656,10 @@ data ChatCommand | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service + | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert + -- episode is last because it is free text: it is the value that makes one occurrence of an + -- alert distinct from the next, and the app returns whatever it was given + | APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text} | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) @@ -846,6 +865,7 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool} + | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} @@ -963,6 +983,8 @@ data ChatEvent | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} | CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object} | CEvtServiceReplySent {connectionId :: AgentConnId} + | CEvtBadgeChanged {user :: User, badgeState :: Maybe BadgeState} -- badge state changed, including a renewal that arrived without a command + | CEvtBadgeAlert {user :: User, badgeAlert :: BadgeAlert} | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response | CEvtAcceptingBusinessRequest {user :: User, groupInfo :: GroupInfo} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index dca70a9c33..a468603e77 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -51,14 +51,19 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) -import Data.Time.Clock (UTCTime, getCurrentTime, nominalDay) +import Data.Word (Word32) +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Type.Equality import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber -import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Crypto.Random (ChaChaDRG) +import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import qualified Simplex.Chat.Badges.Ledger as L +import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) -import Simplex.Chat.Badges.Service (BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), currentBadgeServiceVersion) +import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -100,6 +105,7 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHar import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Agent.Store.Interface (execSQL) import Simplex.Messaging.Agent.Store.Shared (upMigration) @@ -253,6 +259,7 @@ startChatController mainApp enableSndFiles serviceRequests = do startDeliveryWorkers startRelayRequestWorker_ startCleanupManager + mapM_ startBadgeWork users void $ forkIO $ mapM_ startExpireCIs users startRelayChecks users startWebPreview users @@ -349,7 +356,8 @@ restoreCalls = do atomically $ writeTVar calls callsMap stopChatController :: ChatController -> IO () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, badgeWorkers} = do + stopBadgeWorkers badgeWorkers readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd) atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd) disconnectAgentClient smpAgent @@ -581,6 +589,7 @@ processChatCommand cxt nm = \case void . forkIO $ subscribeUsers True users void . forkIO $ startFilesToReceive users setAllExpireCIFlags True + mapM_ startBadgeWork users ok_ APISuspendChat t -> do chatWriteVar chatActivated False @@ -3538,6 +3547,17 @@ processChatCommand cxt nm = \case ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText + APIGetBadgeState userId -> withUserId userId $ \user -> do + -- the read also signals the worker, whose results follow as CEvtBadgeChanged + lift $ startBadgeWork user + CRBadgeState user <$> getUserBadgeState user + APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do + now <- badgeNow + let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing + withStore' $ \db -> setBadgeAlertAcked db badgePurchaseId alertKind episode snoozeUntil + -- after the write, so the pass it signals arms a wake for the snooze rather than raising again + lift $ startBadgeWork user + CRBadgeState user <$> getUserBadgeState user SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -3640,6 +3660,7 @@ processChatCommand cxt nm = \case CLUserContact ucId -> "UserContact " <> tshow ucId CLContactRequest crId -> "ContactRequest " <> tshow crId CLFile fId -> "File " <> tshow fId + CLBadgeUser uId -> "BadgeUser " <> tshow uId DebugEvent event -> toView event >> ok_ GetAgentSubsTotal userId -> withUserId userId $ \user -> do users <- withStore' $ \db -> getUsers db @@ -5135,12 +5156,16 @@ addUserBadge user cred@(BadgeCredential _ _ _ info) = Just False -> throwCmdError "badge credential does not verify against configured key" Just True -> do now <- liftIO getCurrentTime - user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + user' <- withFastStore $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) presentUserBadgeToContacts user' presentUserBadgeToContacts :: User -> CM () -presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do - asks currentUser >>= atomically . (`writeTVar` Just user') +presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadge}} = do + -- a badge worker runs for every profile, not only the active one, so this refreshes the active + -- record where it is the same profile and must never switch to another + chatModifyVar currentUser $ \case + Just User {userId = activeId} | activeId == userId -> Just user' + active_ -> active_ lift $ withAgent' $ \a -> setUserEntitlement a (aUserId user') (badgeServerCredential localBadge) cxt <- asks $ mkStoreCxt . config contacts <- withFastStore' $ \db -> getUserContacts db cxt user' @@ -5157,25 +5182,34 @@ presentUserBadgeToContacts user'@User {profile = LocalProfile {localBadge}} = do -- stashed before the request is sent, so a retry reaches the service as the same signer. -- A terminal answer drops the stash; a timeout keeps it. redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse -redeemBadgeCode nm user codeText = do +redeemBadgeCode nm user@User {userId} codeText = do code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure g <- asks random now <- liftIO getCurrentTime let codeSent = badgeCodeText code - redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- - withStore' $ \db -> - getBadgeCodeRedemption db user codeSent - >>= maybe (createBadgeCodeRedemption db g user codeSent now) pure - let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} - respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req - case J.fromJSON (J.Object respData) of - J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData - J.Success BSPError {code = errCode} -> do - when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) - throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode) - J.Success BSPBadgeCredential {credential = Just cred} -> storeRedeemedBadge user redemption cred - J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData + -- the guard, the request and the write are one section: without it two codes redeemed at once + -- both pass the guard and are both spent, for one badge + (present_, redeemed) <- withEntityLock "badgeRedeem" (CLBadgeUser userId) $ do + redemption_ <- withStore' $ \db -> getBadgeCodeRedemption db user codeSent + -- a code already redeemed here is allowed through: re-sending it returns the badge it bought + -- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent + replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_ + unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwCmdError "badge already active" + redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- + maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_ + let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} + respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req + case J.fromJSON (J.Object respData) of + J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData + J.Success BSPError {code = errCode} -> do + when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) + throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode) + J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement + J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData + -- outside the badge lock: the chat lock must not be taken under it + mapM_ presentUserBadgeToContacts present_ + pure redeemed where -- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on respJSON = LB.unpack . J.encode @@ -5197,10 +5231,317 @@ badgeServiceErrorText = \case where errorCodeChar c = isAsciiLower c || isDigit c || c == '_' --- | Verify the credential before writing anything; the purchase, its issuance and the profile's --- badge go in one transaction, and contacts are told after it commits. -storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> CM ChatResponse -storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeExpiry}) = +-- | The only clock badge code reads, so a test can move the client and the service together. +badgeNow :: CM UTCTime +badgeNow = asks (badgeCurrentTime . config) >>= liftIO + +-- | A signal carries nothing: each pass derives its work from stored state, so a signal lost or +-- duplicated changes no outcome. +startBadgeWork :: User -> CM' () +startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker user + +-- | Exactly one caller starts the thread and the rest wait for it: the lookup and the create cannot +-- be one transaction, because starting a thread is not STM. +getBadgeWorker :: User -> CM' BadgeWorker +getBadgeWorker User {userId} = do + ws <- asks badgeWorkers + seq' <- asks badgeSeq + now <- liftIO getCurrentTime + withGetSessVar' seq' userId ws now startWorker signalWorker + where + startWorker v = do + badgeWork <- newTMVarIO () + badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork + let w = BadgeWorker {badgeWorkerAsync, badgeWork} + w <$ atomically (putTMVar (sessionVar v) w) + signalWorker v = do + w <- atomically $ readTMVar $ sessionVar v + w <$ atomically (void $ tryPutTMVar (badgeWork w) ()) + +-- | The alert last raised, so it is not repeated on every pass. The snooze is in the key because +-- kind and episode do not change when it lapses: the alert would match and stay silent until a restart. +type BadgeOccurrence = (BadgeAlertKind, Text, Maybe UTCTime) + +-- | Nothing ends a pass, so a persistent fault is one attempt per stall interval rather than a hot +-- loop: every error returns a wake, and the wait is outside the retries. +runBadgeWorker :: UserId -> TMVar () -> CM () +runBadgeWorker userId badgeWork = do + emitted <- newTVarIO Nothing + ri <- asks $ badgeRetryInterval . config + forever $ do + at_ <- withRetryInterval ri $ \_ loop -> do + lift waitChatStartedAndActivated + now <- badgeNow + let stalled = pure $ Just $ badgeStalledInterval `addUTCTime` now + updateUserBadge userId emitted now `catchAllErrors` retryBadgeError loop stalled + now <- badgeNow + liftIO $ waitBadgeWake badgeWork now at_ + +retryBadgeError :: CM a -> CM a -> ChatError -> CM a +retryBadgeError loop stalled e = eToView e >> if badgeErrorRetry e then loop else stalled + +-- | The signal is taken only by the wait that reports it - the take and the timer read are one +-- transaction. now is the badge clock, so the remaining time counts down rather than re-reading it. +waitBadgeWake :: TMVar () -> UTCTime -> Maybe UTCTime -> IO () +waitBadgeWake badgeWork now = \case + Nothing -> atomically $ takeTMVar badgeWork + Just at -> waitFor $ diffToMicroseconds $ min badgeMaxWake $ diffUTCTime at now + where + waitFor time + | time <= 0 = pure () + | otherwise = do + let maxWait = min time $ fromIntegral (maxBound :: Int) + timer <- registerDelay $ fromIntegral maxWait + signalled <- atomically $ do + w <- tryTakeTMVar badgeWork + fired <- readTVar timer + unless (isJust w || fired) retry + pure $ isJust w + unless signalled $ waitFor $ time - maxWait + +-- | Bounds the wait: a paidThrough far enough out would overflow the microsecond conversion, +-- wrap negative and spin the worker. Longer than any entitlement, so no real wake is early. +badgeMaxWake :: NominalDiffTime +badgeMaxWake = 100 * 365 * nominalDay + +-- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not +-- at all changes only timing: each run reads stored state and works out what to do. +updateUserBadge :: UserId -> TVar (Maybe BadgeOccurrence) -> UTCTime -> CM (Maybe UTCTime) +updateUserBadge userId emitted now = do + user <- withStore $ \db -> getUser db userId + withStore' (`getUserBadgePurchase` user) >>= \case + Nothing -> pure Nothing + Just p@UserBadgePurchase {badgePurchaseId} -> + withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= \case + Nothing -> pure Nothing + Just balance -> do + -- retirement needs no service and an unbounded retry would not return before it + retired <- retireExpiredBadge user p now balance + latest <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + let requestDue = not retired && badgeRequestDue now (shownBadgeCredential user p) latest balance + (balance', serviceAt) <- + if requestDue + then either ((balance,) . Just) (,Nothing) <$> requestBadgeIssue userId p now + else pure (balance, Nothing) + -- presenting broadcasts the record it is handed, and the request above can block for the + -- whole service timeout, so this read belongs after it and not at the top of the pass + user' <- withStore $ \db -> getUser db userId + -- and the purchase, or an alert acked while the request was in flight is raised again + p' <- fromMaybe p <$> withStore' (`getBadgePurchase` badgePurchaseId) + let issued = balanceStartTs balance' /= balanceStartTs balance + -- outside the badge lock: the chat lock must not be taken under it + unless retired $ presentIssuedBadge user' p' now + emitBadgeAlert user' emitted p' now balance' + -- retiring and presenting both replace the badge on the record read above, so it is read again + user'' <- withStore $ \db -> getUser db userId + when (retired || issued) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user'' + -- a snooze is the one wake that is not in the ledger: nothing else brings the alert back, + -- since support having ended leaves both ledger boundaries in the past + let UserBadgePurchase {alertSnoozeUntil} = p' + snoozeAt = find (> now) alertSnoozeUntil + stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing + pure $ earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance'] + +-- | Support ended is the only alert raised here: the others need subscriptions, and warning before +-- a prepaid badge ends is not actionable while topping up cannot credit months without issuing. +-- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists. +derivedBadgeAlert :: UTCTime -> StatementEntry -> Maybe BadgeAlert +derivedBadgeAlert now b + | balanceMonths b == 0 && endsAt <= now = + Just BadgeAlert {kind = BASupportEnded, episode = safeDecodeUtf8 $ strEncode endsAt, date = endsAt, price = Nothing} + | otherwise = Nothing + where + endsAt = L.paidThrough b + +-- | Derived from state rather than kept pending: raised unless this occurrence is the one already +-- answered, and raised again once a snooze that answered it lapses. +unansweredBadgeAlert :: UTCTime -> UserBadgePurchase -> StatementEntry -> Maybe BadgeAlert +unansweredBadgeAlert now UserBadgePurchase {alertAcked, alertSnoozeUntil} balance = + case derivedBadgeAlert now balance of + Just alert@BadgeAlert {kind, episode} + | alertAcked /= Just (kind, episode) || maybe False (now >=) alertSnoozeUntil -> Just alert + _ -> Nothing + +emitBadgeAlert :: User -> TVar (Maybe BadgeOccurrence) -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM () +emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} now balance = + forM_ (unansweredBadgeAlert now p balance) $ \alert@BadgeAlert {kind, episode} -> do + let occurrence = Just (kind, episode, alertSnoozeUntil) + raised <- atomically $ stateTVar emitted (,occurrence) + when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert + +-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged. +getUserBadgeState :: User -> CM (Maybe BadgeState) +getUserBadgeState user = do + now <- badgeNow + withStore' (`getUserBadgePurchase` user) >>= \case + Nothing -> pure Nothing + Just p@UserBadgePurchase {badgePurchaseId} -> + fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) + where + badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType} balance = + BadgeState + { badgePurchaseId, + badgeType, + monthsLeft = balanceMonths balance, + paidThrough = L.paidThrough balance, + renewsAt = Nothing, + willRenew = False, + alert = unansweredBadgeAlert now p balance + } + +-- | How long a month that did not issue waits before it is tried again, whatever stopped it. Not +-- derived from the failure, so a misclassified one cannot leave a funded badge to expire. +badgeStalledInterval :: NominalDiffTime +badgeStalledInterval = nominalDay + +-- | The wait after a service refusal, floored at initialInterval so answering 0 cannot spin the +-- worker, and uncapped above it. +badgeRetryAfter :: RetryInterval -> Maybe Word32 -> NominalDiffTime +badgeRetryAfter RetryInterval {initialInterval} = maybe badgeStalledInterval (max floorWait . fromIntegral) + where + floorWait = fromIntegral initialInterval / 1000000 + +-- | How far ahead of the shown credential's expiry the renewal is requested - a day, so a failure +-- has that long to retry. The wake and the due check both derive from it and have to agree. +badgeRequestLead :: NominalDiffTime +badgeRequestLead = nominalDay + +-- | The credential the profile is showing for this purchase. Nothing when the purchase is not the +-- one being shown, or when a crash left the issuance written and the profile not. +shownBadgeCredential :: User -> UserBadgePurchase -> Maybe BadgeCredential +shownBadgeCredential User {profile = LocalProfile {localBadge}} UserBadgePurchase {shown} + | not shown = Nothing + | otherwise = case localBadge of + Just (OwnBadge cred _) -> Just cred + _ -> Nothing + +credentialExpiry :: BadgeCredential -> UTCTime +credentialExpiry (BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) = badgeExpiry + +-- | Timed off the shown credential, not the period end: renewing around its shared expiry is what +-- joins the anonymity set. Latest still equal to shown means this month has not been asked for. +badgeRequestDue :: UTCTime -> Maybe BadgeCredential -> Maybe BadgeCredential -> StatementEntry -> Bool +badgeRequestDue now shownCred latestCred balance = + balanceMonths balance > 0 && latestCred == shownCred && maybe False lapsingSoon shownCred + where + lapsingSoon cred = credentialExpiry cred <= badgeRequestLead `addUTCTime` now + +-- | The request and the presentation, a day apart, both read off the credential the profile shows, +-- and the end of what is paid for. The credential's expiry window is what covers renewal, so it +-- says nothing about entitlement: paidThrough is when that ends and the badge has to come off. +-- TODO [badges] every client whose credential shares an expiry requests at the same instant. Only +-- the expiry has to be shared, so the request could fall anywhere in its lead without splitting +-- the anonymity set - spreading the load, and any outage, off a single moment. +badgeBoundary :: UTCTime -> Maybe BadgeCredential -> StatementEntry -> Maybe UTCTime +badgeBoundary now shownCred balance = case filter (> now) moments of + [] -> Nothing + ts -> Just $ minimum ts + where + moments = L.paidThrough balance : maybe [] renewalMoments shownCred + renewalMoments cred = + let expiry = credentialExpiry cred + in [negate badgeRequestLead `addUTCTime` expiry, expiry] + +earliestTime :: [Maybe UTCTime] -> Maybe UTCTime +earliestTime ts = case catMaybes ts of + [] -> Nothing + ts' -> Just $ minimum ts' + +-- | Only a failure that can clear on its own is repeated; every other throw is terminal, and +-- repeating it would spin. Service errors are classified by retryAfter in requestBadgeIssue. +badgeErrorRetry :: ChatError -> Bool +badgeErrorRetry = \case + ChatErrorAgent {agentError} -> retryable agentError + _ -> False + where + -- an unanswered request is the likeliest renewal failure and temporaryOrHostError does not + -- cover it: that classifies reaching the server, and this timeout is the agent's own + retryable = \case + AGENT (A_SERVICE ASETimeout) -> True + e -> temporaryOrHostError e + +-- | Ask the service for the month that is due and apply the response. A timeout writes nothing, so +-- the same request is sent again on the next pass. 'Left' is a service error, already reported, and +-- carries when to try again, since a service error is answered rather than thrown. +requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry) +requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, purchaseKey, purchasePrivKey, masterKey} now = do + sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure + withEntityLock "badgeIssue" (CLBadgeUser userId) $ do + user <- withStore $ \db -> getUser db userId + lastEntry <- withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no entry to assert") pure + let req = + BadgeServiceRequest + { version = currentBadgeServiceVersion, + purchaseKey = Just purchaseKey, + request = BSCIssueBadge {balance = BadgeBalance {lastEntry}} + } + respData <- sendServiceRequestTo NRMBackground user sendTarget Nothing (Just purchasePrivKey) req + case J.fromJSON (J.Object respData) of + J.Success BSPBadgeCredential {credential, statement} -> do + cred_ <- verifyIssuedCredential masterKey credential + -- TODO [badges] the statement is applied either way, so a failed verification spends the + -- month with nothing to show for it; that needs an alert, not only a line in the log + g <- asks random + applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId statement cred_ now + unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against" + Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure) + J.Success BSPError {code, retryAfter} -> do + eToView $ ChatError $ CECommandError $ "badge service error: " <> T.unpack (badgeServiceErrorText code) + ri <- asks $ badgeRetryInterval . config + pure $ Left $ badgeRetryAfter ri retryAfter `addUTCTime` now + _ -> throwCmdError "unexpected badge service response" + +-- | The signature covers the master key inside the credential, so it verifies no matter which key +-- that is - the credential is stored only when that key is also this purchase's. +verifyIssuedCredential :: BadgeMasterKey -> Maybe BadgeCredential -> CM (Maybe BadgeCredential) +verifyIssuedCredential _ Nothing = pure Nothing +verifyIssuedCredential masterKey (Just cred@(BadgeCredential _ credMasterKey _ _)) = + verifyOwnBadge cred >>= \case + Just True | credMasterKey == masterKey -> pure $ Just cred + Just True -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential is for a different master key") + _ -> Nothing <$ eToView (ChatError $ CEInternalError "issued badge credential does not verify") + +-- | Present the newest issued credential once the one on the profile has run out. It is written in +-- a separate transaction from the issuance, so a crash between the two is repaired at the next run. +presentIssuedBadge :: User -> UserBadgePurchase -> UTCTime -> CM () +presentIssuedBadge user p@UserBadgePurchase {badgePurchaseId, shown} now + | not shown = pure () + | otherwise = do + cred_ <- withStore' (`getLatestIssuedCredential` badgePurchaseId) + forM_ cred_ $ \cred@(BadgeCredential _ _ _ info) -> + when (presentDue cred) $ do + user' <- withStore $ \db -> setUserBadge db user (Just $ OwnBadge cred (mkBadgeStatus now (Just True) info)) + presentUserBadgeToContacts user' + where + shownCred = shownBadgeCredential user p + -- Held back until the shown credential lapses, so the broadcast does not correlate with the + -- request that produced it. Nothing shown at all is the state a lost profile write leaves. + presentDue cred = Just cred /= shownCred && maybe True ((<= now) . credentialExpiry) shownCred + +-- | The visible half of "the badge expired". +retireExpiredBadge :: User -> UserBadgePurchase -> UTCTime -> StatementEntry -> CM Bool +retireExpiredBadge user UserBadgePurchase {badgePurchaseId, shown} now balance + | not (shown && L.paidThrough balance <= now) = pure False + | otherwise = do + user' <- withStore $ \db -> do + liftIO $ clearShownBadge db user badgePurchaseId + setUserBadge db user Nothing + True <$ presentUserBadgeToContacts user' + +-- | Waiting on the var rather than skipping an empty one is what catches a worker whose creator +-- had not filled it when the map was swapped out. +stopBadgeWorkers :: TM.TMap UserId (SessionVar BadgeWorker) -> IO () +stopBadgeWorkers workers = + atomically (swapTVar workers M.empty) >>= mapM_ cancelBadgeWorker + where + cancelBadgeWorker v = + void $ forkIO $ atomically (badgeWorkerAsync <$> readTMVar (sessionVar v)) >>= uninterruptibleCancel + +-- | Verify the credential before writing anything; the purchase, the statement's rows, the +-- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about, +-- which the caller does once the badge lock is released. +storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) +storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info) statement = verifyOwnBadge cred >>= \case Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index" Just False -> throwCmdError "redeemed badge credential does not verify against configured key" @@ -5209,16 +5550,37 @@ storeRedeemedBadge user redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCr Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key" Just True -> do g <- asks random - now <- liftIO getCurrentTime + now <- badgeNow let badge = OwnBadge cred (mkBadgeStatus now (Just True) info) - -- TODO [badges] copy the statement's ledger entries, and retire a previously held badge - (user', newBadge) <- withStore' $ \db -> do - newBadge <- createCodeBadgePurchase db g user redemption cred badgeExpiry now + -- TODO [badges] retire a previously held badge + (user', newBadge, applied) <- withStore $ \db -> do + (purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now + applied <- liftIO $ applyBadgeStatement db g purchaseId statement (Just cred) now -- a replay must not put a superseded badge back, or tell every contact again - user' <- if newBadge then setUserBadge db user (Just badge) else pure user - pure (user', newBadge) - when newBadge $ presentUserBadgeToContacts user' - pure $ CRBadgeRedeemed user' badge newBadge + user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId + pure (user', newBadge, applied) + unless applied $ eToView $ ChatError $ CEInternalError "redeemed badge credential has no ledger row to store it against" + -- nothing is due yet, but a pass is what arms the next wake, and this is the first purchase + lift $ startBadgeWork user' + pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge) + +-- | Store the statement's rows, then the credential against the badge debit row among them. +-- 'False' when that row cannot be found, which the caller reports rather than drop in silence. +applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool +applyBadgeStatement db g purchaseId BadgeStatement {entries} cred_ now = do + tip <- getBadgeLedgerLastEntry db purchaseId + storeBadgeStatement db purchaseId tip entries now + case (,) <$> cred_ <*> issuedEntryId of + Nothing -> pure True + Just (cred, entryUuid) -> + getBadgeLedgerEntryId db purchaseId entryUuid >>= \case + Nothing -> pure False + Just entryId -> storeBadgeIssuance db g purchaseId entryId cred now + where + -- the credential belongs to the last month the statement issued + issuedEntryId = case [entryId | StatementEntry {entryId, entryType = SEDebit SDBadge} <- entries] of + [] -> Nothing + ids -> Just (last ids) sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do @@ -5625,6 +5987,8 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), + "/_badge state " *> (APIGetBadgeState <$> A.decimal), + "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), @@ -6044,6 +6408,9 @@ chatCommandP = descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" pure $ if T.null descr then Nothing else Just $ T.take 160 descr textP = safeDecodeUtf8 <$> A.takeByteString + badgeAlertKindP = do + t <- A.takeTill (== ' ') + maybe (fail "bad badge alert kind") pure $ textDecode $ safeDecodeUtf8 t pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) verifyCodeP = safeDecodeUtf8 <$> A.takeWhile (\c -> isDigit c || c == ' ') msgTextP = jsonP <|> textP diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 0eadb1fcb0..d5cb60d6a9 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -7,10 +7,22 @@ module Simplex.Chat.Store.Badges ( BadgeCodeRedemption (..), + UserBadgePurchase (..), + getUserBadgePurchase, + getBadgePurchase, + userHasBadge, + setBadgeAlertAcked, + clearShownBadge, getBadgeCodeRedemption, createBadgeCodeRedemption, deleteBadgeCodeRedemption, createCodeBadgePurchase, + getCodeBadgePurchase, + storeBadgeIssuance, + getLatestIssuedCredential, + storeBadgeStatement, + getBadgeLedgerLastEntry, + getBadgeLedgerEntryId, ) where @@ -19,23 +31,26 @@ import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) +import Data.Maybe (isJust) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges -import Simplex.Chat.Badges.Types (BadgePurchaseStatus (..)) +import Simplex.Chat.Badges.Ledger +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgePurchaseStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (maybeFirstRow, safeDecodeUtf8) +import Simplex.Messaging.Util (decodeJSON, maybeFirstRow, maybeFirstRow', safeDecodeUtf8) #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple (Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else -import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple (Only (..), (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif @@ -90,21 +105,13 @@ deleteBadgeCodeRedemption db redemptionId = |] (redemptionId, redemptionId) --- | The purchase a redeemed code created, its issuance, and the profile's pointer to it. --- False when the code was already redeemed here: the service replays the credential it issued, --- and that must add no purchase and leave the shown badge alone. -createCodeBadgePurchase :: DB.Connection -> TVar ChaChaDRG -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> UTCTime -> IO Bool -createCodeBadgePurchase db g User {userId} redemption credential expiry now = +-- | 'False' when the code was already redeemed here: the service replays the credential it +-- issued, and that must add no purchase and leave the shown badge alone. +createCodeBadgePurchase :: DB.Connection -> User -> BadgeCodeRedemption -> BadgeCredential -> UTCTime -> IO (Int64, Bool) +createCodeBadgePurchase db User {userId} redemption credential now = getCodeBadgePurchase db redemption >>= \case - Just _ -> pure False + Just purchaseId -> pure (purchaseId, False) Nothing -> do - purchaseId <- insertPurchase - DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) - pure True - where - BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption - BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential - insertPurchase = do DB.execute db [sql| @@ -114,19 +121,217 @@ createCodeBadgePurchase db g User {userId} redemption credential expiry now = |] (userId, purchaseKey, purchasePrivKey, Binary mk, badgeType, badgeType, PSIssued, redemptionId, now, now) purchaseId <- insertedRowId db + DB.execute db "UPDATE users SET shown_badge_id = ? WHERE user_id = ?" (purchaseId, userId) + pure (purchaseId, True) + where + BadgeCodeRedemption {redemptionId, purchaseKey, purchasePrivKey, masterKey = BadgeMasterKey mk} = redemption + BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = credential + +-- | The period comes from the ledger, the expiry from the credential, which runs a week longer. +-- 'False' means no issuance row was written, which the caller reports rather than drop in silence. +-- A replayed statement names a month already issued, and one month has one issuance. +storeBadgeIssuance :: DB.Connection -> TVar ChaChaDRG -> Int64 -> Int64 -> BadgeCredential -> UTCTime -> IO Bool +storeBadgeIssuance db g badgePurchaseId entryId credential now = + getIssuedPeriod db badgePurchaseId entryId >>= \case + Nothing -> pure False + Just (periodStart, periodEnd) -> do issuanceId <- safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 g) - -- TODO [badges] the credential's expiry stands in for the period end, which is up to a - -- week later, until the statement carries the real period DB.execute db [sql| - INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) - VALUES (?,?,?,?,?,?,?,?) + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING |] - (issuanceId, purchaseId, badgeType, now, expiry, expiry, Binary (LB.toStrict $ J.encode credential), now) - pure purchaseId + ((issuanceId, badgePurchaseId, entryId, badgeType) :. (periodStart, periodEnd, badgeExpiry, Binary (LB.toStrict $ J.encode credential), now)) + pure True + where + BadgeCredential {badgeInfo = BadgeInfo {badgeType, badgeExpiry}} = credential + +getLatestIssuedCredential :: DB.Connection -> Int64 -> IO (Maybe BadgeCredential) +getLatestIssuedCredential db badgePurchaseId = do + rows <- + DB.query + db + [sql| + SELECT credential FROM badge_issuances + WHERE badge_purchase_id = ? + ORDER BY period_end DESC + LIMIT 1 + |] + (Only badgePurchaseId) + pure $ case rows of + [Only (Binary bs)] -> J.decodeStrict' bs + _ -> Nothing + +-- the start is read from the row before rather than by subtracting a month, which clips +getIssuedPeriod :: DB.Connection -> Int64 -> Int64 -> IO (Maybe (UTCTime, UTCTime)) +getIssuedPeriod db badgePurchaseId entryId = do + rows <- + DB.query + db + [sql| + SELECT + (SELECT prev.balance_start_ts FROM badge_ledger prev + WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id + ORDER BY prev.entry_id DESC LIMIT 1), + issued.balance_start_ts + FROM badge_ledger issued + WHERE issued.badge_purchase_id = ? AND issued.entry_id = ? + |] + (badgePurchaseId, entryId) + -- no preceding row means no credit was ever stored, so the period this row issued is unknown + pure $ case rows of + [(Just periodStart, periodEnd)] -> Just (periodStart, periodEnd) + _ -> Nothing getCodeBadgePurchase :: DB.Connection -> BadgeCodeRedemption -> IO (Maybe Int64) getCodeBadgePurchase db BadgeCodeRedemption {redemptionId} = maybeFirstRow fromOnly $ DB.query db "SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption_id = ?" (Only redemptionId) + +data UserBadgePurchase = UserBadgePurchase + { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, + purchasePrivKey :: C.PrivateKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType, + shown :: Bool, + alertAcked :: Maybe (BadgeAlertKind, Text), + alertSnoozeUntil :: Maybe UTCTime + } + +-- | Newest, not the one shown_badge_id points at - retirement clears that, and the support ended +-- alert is recomputed from this purchase after the badge stops being shown. +getUserBadgePurchase :: DB.Connection -> User -> IO (Maybe UserBadgePurchase) +getUserBadgePurchase db User {userId} = + maybeFirstRow fromOnly newestId >>= maybe (pure Nothing) (getBadgePurchase db) + where + newestId = + DB.query + db + [sql| + SELECT badge_purchase_id FROM badge_purchases + WHERE user_id = ? AND purchase_priv_key IS NOT NULL + ORDER BY badge_purchase_id DESC + LIMIT 1 + |] + (Only userId) + +-- shown is a CASE because in Postgres a comparison is boolean, which BoolInt rejects. +getBadgePurchase :: DB.Connection -> Int64 -> IO (Maybe UserBadgePurchase) +getBadgePurchase db purchaseId = + maybeFirstRow toPurchase $ + DB.query + db + [sql| + SELECT p.badge_purchase_id, p.purchase_key, p.purchase_priv_key, p.master_key, p.current_badge_type, + (CASE WHEN u.shown_badge_id = p.badge_purchase_id THEN 1 ELSE 0 END), + p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until + FROM badge_purchases p + JOIN users u ON u.user_id = p.user_id + WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL + |] + (Only purchaseId) + where + toPurchase (badgePurchaseId, purchaseKey, purchasePrivKey, Binary mk, badgeType, shown_, ackedKind_, ackedEpisode_, alertSnoozeUntil) = + UserBadgePurchase + { badgePurchaseId, + purchaseKey, + purchasePrivKey, + masterKey = BadgeMasterKey mk, + badgeType, + shown = unBI shown_, + alertAcked = (,) <$> ackedKind_ <*> ackedEpisode_, + alertSnoozeUntil + } + +-- | Whether a badge is on the profile now: set when a redemption stores one, cleared when it is +-- retired. Read as the id rather than as a comparison, which in Postgres would be a boolean. +userHasBadge :: DB.Connection -> User -> IO Bool +userHasBadge db User {userId} = + maybeFirstRow' False shownBadge $ + DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only userId) + where + shownBadge :: Only (Maybe Int64) -> Bool + shownBadge = isJust . fromOnly + +-- | An ack and a snooze both record the occurrence answered; a snooze also records how long it +-- holds, so that it silences that occurrence and not whichever one is derived next. +setBadgeAlertAcked :: DB.Connection -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO () +setBadgeAlertAcked db badgePurchaseId kind episode snoozeUntil = + DB.execute + db + "UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?" + (kind, episode, snoozeUntil, badgePurchaseId) + +-- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller. +clearShownBadge :: DB.Connection -> User -> Int64 -> IO () +clearShownBadge db User {userId} badgePurchaseId = + DB.execute db "UPDATE users SET shown_badge_id = NULL WHERE user_id = ? AND shown_badge_id = ?" (userId, badgePurchaseId) + +-- | Verbatim, entry_uuid and type included: the client authors no row, or the two sides stop +-- holding the same ledger. DO NOTHING makes a re-applied statement a no-op rather than a throw. +-- An entry whose balance does not follow from the one before it is stored and marked, not refused. +storeBadgeStatement :: DB.Connection -> Int64 -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO () +storeBadgeStatement db badgePurchaseId tip entries now = + mapM_ storeEntry $ balanceChecked tip entries + where + storeEntry (StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}, checked) = + DB.execute + db + [sql| + INSERT INTO badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, + entry_type_unknown, entry_type_value, balance_checked) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (entry_uuid) DO NOTHING + |] + ( (entryId, badgePurchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince) + :. (createdAt, now, entryTypeT, creditType, debitType, BI typeUnknown, entryTypeValue, BI <$> checked) + ) + where + (entryTypeT, creditType, debitType) = entryTypeColumns entryType + -- kept for every entry, not only for a type this version cannot decode: a tag alone does + -- not rebuild the types that name an invoice, a charge or another purchase + entryTypeValue = safeDecodeUtf8 . LB.toStrict $ case entryType of + SECredit c -> J.encode c + SEDebit d -> J.encode d + typeUnknown = case entryType of + SECredit SCUnknown {} -> True + SEDebit SDUnknown {} -> True + _ -> False + +-- | The balance is the last row; nothing derives it by summing the history. +getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) +getBadgeLedgerLastEntry db badgePurchaseId = + maybeFirstRow' Nothing toEntry $ + DB.query + db + [sql| + SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, entry_type, entry_credit_type, entry_debit_type, entry_type_value + FROM badge_ledger + WHERE badge_purchase_id = ? + ORDER BY entry_id DESC + LIMIT 1 + |] + (Only badgePurchaseId) + where + toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) + <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + +-- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since +-- learnt the type reads it with its fields, and one that has not still gets it back verbatim. +entryTypeFromValue :: Text -> Text -> Maybe StatementEntryType +entryTypeFromValue entryTypeT value_ = case entryTypeT of + "credit" -> SECredit <$> decodeJSON value_ + "debit" -> SEDebit <$> decodeJSON value_ + _ -> Nothing + +getBadgeLedgerEntryId :: DB.Connection -> Int64 -> Text -> IO (Maybe Int64) +getBadgeLedgerEntryId db badgePurchaseId entryUuid = + maybeFirstRow fromOnly $ + DB.query db "SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ?" (badgePurchaseId, entryUuid) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs index 4606e7c307..1e8042160c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs @@ -143,6 +143,7 @@ CREATE TABLE @badge_ledger( change_months SMALLINT NOT NULL, balance_months SMALLINT NOT NULL, balance_start_ts TIMESTAMPTZ NOT NULL, + balance_anchor_ts TIMESTAMPTZ NOT NULL, balance_badge_type TEXT NOT NULL, was_paused_since TIMESTAMPTZ, service_created_at TIMESTAMPTZ NOT NULL, @@ -183,6 +184,8 @@ CREATE TABLE @badge_issuances( CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); |] badgeSchemaTablesDown :: Text @@ -190,6 +193,7 @@ badgeSchemaTablesDown = [r| DROP INDEX @idx_badge_issuances_purchase; DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; DROP TABLE @badge_issuances; DROP INDEX @idx_badge_ledger_uuid; DROP INDEX @idx_badge_ledger_purchase; @@ -237,6 +241,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; +ALTER TABLE badge_ledger ADD COLUMN balance_checked SMALLINT; + CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id BIGINT REFERENCES badge_purchases ON DELETE SET NULL; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 958f597c12..0943befa82 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -223,6 +223,7 @@ CREATE TABLE test_chat_schema.badge_ledger ( change_months smallint NOT NULL, balance_months smallint NOT NULL, balance_start_ts timestamp with time zone NOT NULL, + balance_anchor_ts timestamp with time zone NOT NULL, balance_badge_type text NOT NULL, was_paused_since timestamp with time zone, service_created_at timestamp with time zone NOT NULL, @@ -235,7 +236,8 @@ CREATE TABLE test_chat_schema.badge_ledger ( from_purchase_id bigint, to_purchase_id bigint, entry_type_unknown smallint DEFAULT 0 NOT NULL, - entry_type_value text + entry_type_value text, + balance_checked smallint ); @@ -2214,6 +2216,10 @@ CREATE INDEX idx_badge_issuances_purchase ON test_chat_schema.badge_issuances US +CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON test_chat_schema.badge_issuances USING btree (badge_purchase_id, entry_id); + + + CREATE INDEX idx_badge_ledger_charge ON test_chat_schema.badge_ledger USING btree (charge_id); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 965cbcbc78..20e05009a5 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -382,19 +382,21 @@ updateUserProfileFields_' db userId profileId Profile {displayName, fullName, sh -- store the user's own badge credential; touches only the badge columns. -- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message. -setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> IO User -setUserBadge db user@User {userId, profile = p@LocalProfile {profileId}} localBadge = do - ts <- getCurrentTime - DB.execute - db - [sql| - UPDATE contact_profiles - SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? - WHERE user_id = ? AND contact_profile_id = ? - |] - (localBadgeToRow localBadge :. (ts, userId, profileId)) - DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) - pure (user :: User) {profile = p {localBadge}, userMemberProfileUpdatedAt = Just ts} +-- answers the row as stored, or a profile edit landing since the caller's read is broadcast back stale. +setUserBadge :: DB.Connection -> User -> Maybe LocalBadge -> ExceptT StoreError IO User +setUserBadge db User {userId, profile = LocalProfile {profileId}} localBadge = do + liftIO $ do + ts <- getCurrentTime + DB.execute + db + [sql| + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + |] + (localBadgeToRow localBadge :. (ts, userId, profileId)) + DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (ts, userId) + getUser db userId setUserSimplexDomain :: DB.Connection -> User -> Maybe SimplexDomain -> IO User setUserSimplexDomain db user@User {userId, profile = p@LocalProfile {profileId}} domain_ = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs index ae1e138888..652eaac7ab 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs @@ -144,6 +144,7 @@ CREATE TABLE @badge_ledger( change_months INTEGER NOT NULL, balance_months INTEGER NOT NULL, balance_start_ts TEXT NOT NULL, + balance_anchor_ts TEXT NOT NULL, balance_badge_type TEXT NOT NULL, was_paused_since TEXT, service_created_at TEXT NOT NULL, @@ -184,6 +185,8 @@ CREATE TABLE @badge_issuances( CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); |] badgeSchemaTablesDown :: Query @@ -191,6 +194,7 @@ badgeSchemaTablesDown = [sql| DROP INDEX @idx_badge_issuances_purchase; DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; DROP TABLE @badge_issuances; DROP INDEX @idx_badge_ledger_uuid; DROP INDEX @idx_badge_ledger_purchase; @@ -238,6 +242,8 @@ ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; +ALTER TABLE badge_ledger ADD COLUMN balance_checked INTEGER; + CREATE INDEX idx_badge_purchases_user ON badge_purchases(user_id); ALTER TABLE users ADD COLUMN shown_badge_id INTEGER REFERENCES badge_purchases ON DELETE SET NULL; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index fe354474b0..c9a0cbb669 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1204,8 +1204,19 @@ Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) Query: - INSERT INTO badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) - VALUES (?,?,?,?,?,?,?,?) + INSERT INTO badge_issuances (issuance_id, badge_purchase_id, entry_id, badge_type, period_start, period_end, expiry, credential, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (badge_purchase_id, entry_id) DO NOTHING + +Plan: + +Query: + INSERT INTO badge_ledger + (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts, balance_badge_type, + was_paused_since, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, + entry_type_unknown, entry_type_value, balance_checked) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (entry_uuid) DO NOTHING Plan: @@ -2017,6 +2028,20 @@ Query: Plan: +Query: + SELECT + (SELECT prev.balance_start_ts FROM badge_ledger prev + WHERE prev.badge_purchase_id = issued.badge_purchase_id AND prev.entry_id < issued.entry_id + ORDER BY prev.entry_id DESC LIMIT 1), + issued.balance_start_ts + FROM badge_ledger issued + WHERE issued.badge_purchase_id = ? AND issued.entry_id = ? + +Plan: +SEARCH issued USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 1 +SEARCH prev USING INDEX idx_badge_ledger_purchase (badge_purchase_id=? AND entry_id ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] + CRBadgeState u st -> ttyUser u $ viewUserBadgeState st CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -476,6 +478,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} <> maybe [] (\k -> [plain $ "signed by " <> safeDecodeUtf8 (strEncode k)]) sigKey_ <> ["request: " <> viewJSON req] CEvtServiceReplySent (AgentConnId cId) -> [plain $ "service reply sent, connection id: " <> safeDecodeUtf8 (strEncode cId)] + CEvtBadgeChanged u st -> ttyUser u $ viewUserBadgeState st + CEvtBadgeAlert u alert -> ttyUser u $ viewBadgeAlert alert CEvtContactRequestRejected u Contact {localDisplayName = c} _reason -> ttyUser u [ttyContact c <> ": contact request rejected"] CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -1829,9 +1833,30 @@ viewContactBadge = maybe [] $ \lb -> BSExpiredOld -> "expired (old)" BSFailed -> "verification failed" BSUnknownKey -> "unknown key" - expiry = "expires " <> T.pack (formatTime defaultTimeLocale "%Y-%m-%d" badgeExpiry) + expiry = "expires " <> day badgeExpiry in [plain (textEncode badgeType <> " badge - " <> st), plain expiry] +viewUserBadgeState :: Maybe BadgeState -> [StyledString] +viewUserBadgeState = maybe [] viewBadge + where + viewBadge BadgeState {badgePurchaseId, badgeType, monthsLeft, paidThrough, alert} = + plain + ( tshow badgePurchaseId + <> ": " + <> textEncode badgeType + <> ", " + <> tshow monthsLeft + <> " months left, paid through " + <> day paidThrough + ) + : maybe [] viewBadgeAlert alert + +viewBadgeAlert :: BadgeAlert -> [StyledString] +viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] + +day :: UTCTime -> Text +day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index bd2067c928..e26de4823d 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -9,18 +9,30 @@ module BadgeTests (badgeTests) where +import BadgeService.Service (badgeErrorRetryAfter) import Control.Concurrent.STM (atomically) import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Calendar (fromGregorian) +import Data.Time.Calendar.WeekDate (toWeekDate) +import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM +import Data.Maybe (fromMaybe, isNothing, maybeToList) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges import Simplex.Chat.Badges.Code +import Simplex.Chat.Badges.Ledger import Simplex.Chat.Badges.Service +import Simplex.Chat (defaultChatConfig) +import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent) +import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeStalledInterval) +import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..)) +import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay) import Simplex.Messaging.Crypto.BBS +import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..)) import Simplex.Messaging.Version.Internal (Version (..)) import Test.Hspec @@ -39,6 +51,20 @@ badgeTests = do it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation it "rejects a code whose check character does not match" testCodeCheckCharacter it "hashes the canonical form, whatever was typed" testCodeHash + describe "ledger transitions" $ do + it "issues a twelve month code one month at a time, and no thirteenth" testTwelveMonths + it "lapses only the elapsed months after a gap, leaving paidThrough unchanged" testLapseAfterGap + it "keeps the balance non-negative and the start non-decreasing" testLedgerInvariants + it "credits an exhausted balance from the grant, not from the date it ran out" testGrantAfterExhausted + it "does not issue months topped up inside an issued period" testGrantInsideIssuedPeriod + it "clips month ends without losing the issued period start" testMonthEndClipping + it "expires at the end of the Monday after the period" testMondayExpiry + it "stores the wire tag of every entry type, and rebuilds each from its stored JSON" testEntryTypeColumns + describe "worker retry" $ do + it "repeats a failure that can clear on its own, and no other" testRetryClassification + it "backs off to the cap" testRetryBackoff + it "floors the wait a service asks for, and honours anything above it" testServiceRetryFloor + it "sends retryAfter with the transient service codes and no other" testServiceRetryAfter describe "service protocol JSON" $ do it "redeemBadgeCode request matches the schema" testRedeemRequestJSON it "badgeCredential response matches the schema" testCredentialResponseJSON @@ -48,6 +74,9 @@ badgeTests = do proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p +nonDecreasing :: Ord a => [a] -> Bool +nonDecreasing xs = and $ zipWith (<=) xs (drop 1 xs) + testKeyIdx :: Int testKeyIdx = 1 @@ -204,6 +233,260 @@ testCodeHash = do Just typed <- pure $ parseBadgeCode $ T.toLower $ formatBadgeCode code badgeCodeHash typed `shouldBe` badgeCodeHash code +-- Ledger transitions, against plans/2026-07-30-supporter-badges-v3-ux.md §3 + +at :: Integer -> Int -> Int -> UTCTime +at y m d = UTCTime (fromGregorian y m d) (11 * 3600) + +newBalance :: UTCTime -> StatementEntry +newBalance t = emptyEntry t BTSupporter + +-- these tests never write, so the id each operation stamps on its entry is never read +grant :: UTCTime -> Int -> StatementEntry -> StatementEntry +grant t n = grantEntry t "" n SCCode + +lapse :: UTCTime -> StatementEntry -> Maybe StatementEntry +lapse t = lapseEntry t "" + +issue :: UTCTime -> StatementEntry -> Maybe StatementEntry +issue t = issueEntry t "" + +-- StatementEntry and BadgeInfo carry fields of the same names, so the selectors are ambiguous here +bMonths :: StatementEntry -> Int +bMonths StatementEntry {balanceMonths} = balanceMonths + +bStart :: StatementEntry -> UTCTime +bStart StatementEntry {balanceStartTs} = balanceStartTs + +-- one service pass: lapse what elapsed, then issue if a month is due, as the service chains them. +-- The period an issue covers is the previous entry's balance start to its own, so a run of starts +-- is what the period assertions read. +pass :: UTCTime -> StatementEntry -> [StatementEntry] +pass t e0 = maybeToList lapsed <> maybeToList (issue t $ fromMaybe e0 lapsed) + where + lapsed = lapse t e0 + +finalBalance :: StatementEntry -> [StatementEntry] -> StatementEntry +finalBalance e0 rows = last (e0 : rows) + +-- each pass issues exactly one month, so the entries returned are the months issued, in order +issueAll :: StatementEntry -> [StatementEntry] +issueAll e = case pass (bStart e) e of + [] -> [] + rows -> let e' = finalBalance e rows in e' : issueAll e' + +testTwelveMonths :: IO () +testTwelveMonths = do + let start = at 2026 3 10 + granted = grant start 12 (newBalance start) + -- each month is issued as soon as it falls due + issued = issueAll granted + spent = finalBalance granted issued + length issued `shouldBe` 12 + bMonths spent `shouldBe` 0 + -- no month was skipped or issued twice: consecutive starts, so the periods tile the whole year + map bStart (granted : issued) `shouldBe` map (\m -> addMonths m start) [0 .. 12] + bStart spent `shouldBe` at 2027 3 10 + -- a thirteenth request issues nothing, whenever it is made + issue (bStart spent) spent `shouldSatisfy` isNothing + issue (at 2030 1 1) spent `shouldSatisfy` isNothing + lapse (at 2030 1 1) spent `shouldSatisfy` isNothing + +-- 3 months bought 10 Mar, first issued the same day, no pass until 20 May: April lapses unissued +testLapseAfterGap :: IO () +testLapseAfterGap = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + rows1 = pass start granted + afterFirst = finalBalance granted rows1 + rows2 = pass (at 2026 5 20) afterFirst + afterSecond = finalBalance afterFirst rows2 + map bMonths rows1 `shouldBe` [2] + -- March is issued: from the granting entry's start to the issued entry's own + bStart granted `shouldBe` at 2026 3 10 + map bStart rows1 `shouldBe` [at 2026 4 10] + -- one lapse row for April, then May is issued: two rows, not one and not three. The lapse row's + -- start is where May begins, so it is also the start of the period the issue that follows covers + map bMonths rows2 `shouldBe` [1, 0] + map bStart rows2 `shouldBe` [at 2026 5 10, at 2026 6 10] + -- a lapse moves months from unused to gone; it never changes what was paid for + map paidThrough (granted : rows1 <> rows2) `shouldBe` replicate 4 (at 2026 6 10) + bMonths afterSecond `shouldBe` 0 + +data LedgerStep = Grant UTCTime Int | Pass UTCTime + +testLedgerInvariants :: IO () +testLedgerInvariants = do + let start = at 2026 1 15 + reopened = at 2027 9 9 + -- the April grant lands exactly where coverage ended and continues the run, the 2027 one + -- lands past it and restarts, so both branches of grantEntry run under the invariants + steps = + [ Grant start 3, + Pass start, + Pass (at 2026 4 15), + Grant (at 2026 4 15) 2, + Pass (at 2026 5 1), + Pass reopened, + Grant reopened 1, + Pass reopened + ] + step (e, rows) = \case + Grant t n -> let e' = grant t n e in (e', rows <> [e']) + Pass t -> let rs = pass t e in (finalBalance e rs, rows <> rs) + (_, allRows) = foldl step (newBalance start, []) steps + map bMonths allRows `shouldSatisfy` all (>= 0) + map bStart allRows `shouldSatisfy` nonDecreasing + +testGrantAfterExhausted :: IO () +testGrantAfterExhausted = do + -- the balance ran out on 10 Feb; the next code is redeemed on 1 Jun + let spent = newBalance (at 2026 2 10) + granted = grant (at 2026 6 1) 2 spent + bStart granted `shouldBe` at 2026 6 1 + paidThrough granted `shouldBe` at 2026 8 1 + -- the four unsupported months are not backfilled, so nothing lapses immediately + lapse (at 2026 6 1) granted `shouldSatisfy` isNothing + +testGrantInsideIssuedPeriod :: IO () +testGrantInsideIssuedPeriod = do + let start = at 2026 1 10 + granted = grant start 1 (newBalance start) + issued = finalBalance granted $ pass start granted + -- topped up on 20 Jan, while the month issued on 10 Jan still runs + toppedUp = grant (at 2026 1 20) 3 issued + -- February is where the next period starts, the top-up having been spent on neither January nor a gap + bStart toppedUp `shouldBe` at 2026 2 10 + paidThrough toppedUp `shouldBe` at 2026 5 10 + -- the balance starts in the future, so no second credential is issued for January + issue (at 2026 1 20) toppedUp `shouldSatisfy` isNothing + fmap bStart (issue (at 2026 2 10) toppedUp) `shouldBe` Just (at 2026 3 10) + +testMonthEndClipping :: IO () +testMonthEndClipping = do + let start = at 2027 1 31 + granted = grant start 3 (newBalance start) + issued = issueAll granted + -- February clips to the 28th, and March goes back to the 31st: clipping does not accumulate. + -- Each period runs from one start to the next, so these bounds are the three periods + map bStart (granted : issued) `shouldBe` [at 2027 1 31, at 2027 2 28, at 2027 3 31, at 2027 4 30] + -- the issued period start is the previous balance start, never periodEnd minus a month, which + -- clipping would answer as 28 Jan + addMonths (-1) (bStart (head issued)) `shouldNotBe` bStart granted + -- across a leap day + let leap = grant (at 2028 1 29) 2 (newBalance (at 2028 1 29)) + map bStart (issueAll leap) `shouldBe` [at 2028 2 29, at 2028 3 29] + -- a month that ends on the leap day counts as elapsed the moment it ends, and not before + fmap bMonths (lapse (at 2028 2 29) leap) `shouldBe` Just 1 + lapse (addUTCTime (-1) (at 2028 2 29)) leap `shouldSatisfy` isNothing + -- buying a month at a time keeps the day of month that buying three at once keeps + let jan = grant (at 2027 1 31) 1 (newBalance (at 2027 1 31)) + case issue (at 2027 1 31) jan of + Just issuedJan -> do + let feb = grant (at 2027 2 20) 1 issuedJan + fmap bStart (issue (at 2027 2 28) feb) `shouldBe` Just (at 2027 3 31) + Nothing -> expectationFailure "January was not issued" + +testMondayExpiry :: IO () +testMondayExpiry = do + -- the end of Monday 13 Apr is Tuesday 14 Apr 00:00 + endOfMondayAfter (at 2026 4 10) `shouldBe` UTCTime (fromGregorian 2026 4 14) 0 + endOfMondayAfter (at 2026 6 10) `shouldBe` UTCTime (fromGregorian 2026 6 16) 0 + -- a period ending on a Monday still runs to the end of the following Monday, never to zero days + endOfMondayAfter (at 2026 4 13) `shouldBe` UTCTime (fromGregorian 2026 4 21) 0 + let periodEnds = map (\d -> at 2026 4 d) [1 .. 30] + expiries = map endOfMondayAfter periodEnds + -- every expiry is a Tuesday midnight more than a day after its period, and at most eight + expiries `shouldSatisfy` all (\(UTCTime d t) -> t == 0 && (\(_, _, wd) -> wd == 2) (toWeekDate d)) + zipWith diffUTCTime expiries periodEnds `shouldSatisfy` all (\d -> d > nominalDay && d <= 8 * nominalDay) + +-- A failed renewal is otherwise left until the next chat start or activate, which on a desktop +-- left running can be days - long enough for a funded badge to lapse. +testRetryClassification :: IO () +testRetryClassification = do + let retryFor = badgeErrorRetry . chatErrorAgent + -- an unanswered request is the likeliest renewal failure, and it is the agent's own error + retryFor (AGENT (A_SERVICE ASETimeout)) `shouldBe` True + retryFor (BROKER "localhost" TIMEOUT) `shouldBe` True + retryFor (BROKER "localhost" (NETWORK NETimeoutError)) `shouldBe` True + -- terminal: the same request would fail the same way, and repeating it would spin + retryFor (AGENT (A_SERVICE ASEBadSignature)) `shouldBe` False + retryFor (AGENT (A_SERVICE (ASERejected "no"))) `shouldBe` False + badgeErrorRetry (ChatError (CECommandError "unexpected badge service response")) `shouldBe` False + +-- A failure that never clears is repeated for as long as the balance funds a month, so the wait +-- has to grow: at a fixed interval an annual code would ask hundreds of times a day, all year. +testRetryBackoff :: IO () +testRetryBackoff = do + let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig + advance (elapsed, delay) = + let elapsed' = elapsed + delay + in (elapsed', nextRetryDelay elapsed' delay ri) + delays = map snd $ take 40 $ iterate advance (0, initialInterval) + head delays `shouldBe` initialInterval + delays `shouldSatisfy` all (\d -> d >= initialInterval && d <= maxInterval) + delays `shouldSatisfy` nonDecreasing + -- it reaches the cap rather than creeping towards it, and stays there + last delays `shouldBe` maxInterval + +-- A service answering retryAfter 0 would put the next attempt at now, and the worker would ask +-- again as fast as the round trip allows, for as long as the service kept answering that way. +testServiceRetryFloor :: IO () +testServiceRetryFloor = do + let ri@RetryInterval {initialInterval, maxInterval} = badgeRetryInterval defaultChatConfig + floorWait = fromIntegral initialInterval / 1000000 :: NominalDiffTime + aboveCap = 2 * fromIntegral maxInterval / 1000000 :: NominalDiffTime + -- a code carrying no wait is terminal for this request, and waits what any stalled month waits + badgeRetryAfter ri Nothing `shouldBe` badgeStalledInterval + -- nothing the service names brings the wait below where a retry of its own would start + badgeRetryAfter ri (Just 0) `shouldBe` floorWait + badgeRetryAfter ri (Just 1) `shouldBe` floorWait + badgeRetryAfter ri (Just $ round floorWait) `shouldBe` floorWait + -- above that it is honoured as sent, and not capped: a service may know it is down for the day + badgeRetryAfter ri (Just 600) `shouldBe` 600 + badgeRetryAfter ri (Just $ round aboveCap) `shouldBe` aboveCap + +-- badges-rpc.md defines retryAfter as marking the transient codes, and every other code as +-- terminal for the command attempted. The client repeats a code that carries one on the service's +-- schedule, so the set is the protocol's and not a judgement to make per call site. +testServiceRetryAfter :: IO () +testServiceRetryAfter = do + badgeErrorRetryAfter BSEPaymentPending `shouldBe` Just 300 + badgeErrorRetryAfter BSEProviderUnavailable `shouldBe` Just 300 + badgeErrorRetryAfter BSERateLimited `shouldBe` Just 60 + -- internal is the one most likely to clear on its own, and is still terminal: repeating it on + -- the service's cadence presses a service already failing, and the client has its own floor + badgeErrorRetryAfter BSEInternal `shouldBe` Nothing + mapM_ + (\code -> badgeErrorRetryAfter code `shouldBe` Nothing) + [BSEBadRequest, BSEUnsupportedVersion, BSEUnknownPurchaseKey, BSECodeInvalid, BSECodeUsed, BSECodeExpired, BSEUnknown "future_code"] + +-- The client replicates entry_credit_type / entry_debit_type verbatim, so a stored tag that +-- disagreed with the wire tag would put a different row on each side. +testEntryTypeColumns :: IO () +testEntryTypeColumns = do + k <- fst <$> (C.newRandom >>= \g -> atomically (C.generateKeyPair g) :: IO (C.KeyPair 'C.Ed25519)) + let credits = [SCPayment Nothing, SCCode, SCCharge "ch1", SCSupport, SCTransferIn k, SCOpening] + debits = [SDRefund, SDUpgrade k, SDTransferOut k, SDSupport, SDBadge, SDLapse] + mapM_ (\c -> wireTag (J.toJSON (SECredit c)) "credit" `shouldBe` Just (creditTypeTag c)) credits + mapM_ (\d -> wireTag (J.toJSON (SEDebit d)) "debit" `shouldBe` Just (debitTypeTag d)) debits + -- the three types this version writes survive a round trip through the columns + mapM_ + (\t -> uncurry3 entryTypeFromColumns (entryTypeColumns t) `shouldSatisfy` sameEntryType t) + [SECredit SCCode, SEDebit SDBadge, SEDebit SDLapse] + -- a type that needs a reference column is not silently read back as something else + uncurry3 entryTypeFromColumns (entryTypeColumns (SECredit (SCCharge "ch1"))) `shouldSatisfy` isNothing + -- which is why every type is stored as its own JSON as well, and read from that first: the + -- columns alone would answer a row naming an invoice or a purchase as no row at all + mapM_ roundTrips credits + mapM_ roundTrips debits + where + uncurry3 f (a, b, c) = f a b c + sameEntryType t = maybe False ((J.toJSON t ==) . J.toJSON) + wireTag v fld = case v of + J.Object o | Just (J.Object inner) <- KM.lookup fld o, Just (J.String t) <- KM.lookup "type" inner -> Just t + _ -> Nothing + -- Service protocol JSON, against docs/protocol/badges-rpc.schema.json testRedeemRequestJSON :: IO () @@ -255,6 +538,7 @@ testStatementJSON = do changeMonths = 3, balanceMonths = 3, balanceStartTs = futureTime, + balanceAnchorTs = futureTime, balanceBadgeType = BTSupporter, wasPausedSince = Nothing, createdAt = futureTime, @@ -267,6 +551,7 @@ testStatementJSON = do "changeMonths" J..= (3 :: Int), "balanceMonths" J..= (3 :: Int), "balanceStartTs" J..= futureTime, + "balanceAnchorTs" J..= futureTime, "balanceBadgeType" J..= ("supporter" :: T.Text), "createdAt" J..= futureTime, "entryType" J..= entryType entry @@ -274,6 +559,10 @@ testStatementJSON = do J.toJSON entry {wasPausedSince = Just pastTime} `shouldNotBe` J.toJSON entry J.toJSON (entryType entry) `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("payment" :: T.Text)]] J.toJSON SEDebit {debit = SDBadge} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("badge" :: T.Text)]] + J.toJSON SEDebit {debit = SDLapse} `shouldBe` J.object ["type" J..= ("debit" :: T.Text), "debit" J..= J.object ["type" J..= ("lapse" :: T.Text)]] + -- a code grant is its own credit type, not a payment whose invoiceId happens to be absent + J.toJSON SECredit {credit = SCCode} `shouldBe` J.object ["type" J..= ("credit" :: T.Text), "credit" J..= J.object ["type" J..= ("code" :: T.Text)]] + J.toJSON SECredit {credit = SCCode} `shouldNotBe` J.toJSON SECredit {credit = SCPayment {invoiceId = Nothing}} -- an entry type from a newer service is stored and re-emitted unchanged let futureCredit = J.object ["type" J..= ("grant" :: T.Text), "grantedBy" J..= ("operator" :: T.Text)] case J.fromJSON futureCredit of diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 84daff15f8..a45e8040b0 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -1,8 +1,12 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Bots.BadgeServiceTests where @@ -13,23 +17,42 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Concurrent.STM (atomically, readTMVar) +import Control.Monad (void, when) import Control.Exception (finally) +import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B import Data.Char (toLower) import Data.Either (isLeft, isRight) +import Data.Int (Int64) +import Data.IORef (IORef, newIORef, readIORef, writeIORef) import qualified Data.Map.Strict as M +import Data.Maybe (isJust, isNothing) +import Data.String (fromString) +import System.Timeout (timeout) +import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.Badges (BadgeType (..)) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) -import Simplex.Chat.Controller (ChatConfig (..), ChatController, ChatResponse (CRCustomChatResponse)) +import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) +import Simplex.Chat.Badges.Service +import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse)) import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (BBSSecretKey, bbsKeyGen) import Simplex.Messaging.Encoding.String (strDecode, strEncode, textEncode) +import Simplex.Messaging.Util (safeDecodeUtf8) import System.FilePath (()) +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif import Test.Hspec hiding (it) badgeServiceTests :: SpecWith TestParams @@ -39,10 +62,28 @@ badgeServiceTests = do it "should return the same badge when the same code is redeemed twice" testRedeemBadgeCodeTwice it "should answer code_invalid to an unknown code, indistinguishably from a malformed one" testRedeemUnknownCode it "should tell a second profile redeeming the same code that it is used" testRedeemSameCodeOtherProfile - it "should redeem a second code, and not restore the first badge on replay" testRedeemSecondCode + it "should refuse a second code while a badge is held, leaving it unspent" testRedeemSecondCode it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig + it "should credit a code's months and issue one credential per month" testCodeMonthsRenew + it "should return the stored credential for a repeat inside an issued period" testRepeatInsideIssuedPeriod + it "should lapse only the months that elapsed while the client was away" testLapseWhileAway + it "should round the last month's expiry up to the end of the Monday after it" testLastMonthExpiryRounds + it "should sign a renewal with the master key stored on the purchase" testRenewalSignsWithStoredMasterKey + it "should leave the client holding the same ledger rows as the service" testClientReplicatesLedger + it "should renew a badge whose credential is lapsing, with no command" testWorkerRenews + it "should request from the wake it set a day before the credential lapses" testRequestWakeFires + it "should present from the wake it set at the credential's expiry" testPresentWakeFires + it "should renew a badge whose newest ledger row is of an unknown type" testRenewsAfterUnknownEntry + it "should catch up the months that lapsed while the client was stopped" testRenewsAfterRestart + it "should stop showing a badge whose balance ran out, and tell contacts" testWorkerRetiresExpired + it "should retire when entitlement ends, not when the credential expires" testRetiresWhenEntitlementEnds + it "should alert that support ended, survive a restart, and go silent once acknowledged" testEndedAlert + it "should raise a snoozed alert once more when the snooze lapses" testSnoozedAlertReturns + it "should renew a badge on a profile that is not active, without switching to it" testRenewalKeepsActiveProfile + it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits + it "should present the month already issued when a previous pass did not" testPresentationCatchesUp badgeProfile :: Profile badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing} @@ -74,14 +115,45 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey = testing = True } +-- | A clock the service and the client both read: real time plus an offset the test moves. It +-- tracks real time rather than freezing it, so a sleeper still sleeps the right real duration. +newtype TestClock = TestClock (IORef NominalDiffTime) + +newTestClock :: IO TestClock +newTestClock = TestClock <$> newIORef 0 + +testClockTime :: TestClock -> IO UTCTime +testClockTime (TestClock r) = do + offset <- readIORef r + addUTCTime offset <$> getCurrentTime + +-- | Move the clock so that "now" becomes exactly the given time - months are calendar months, so +-- a test crosses a boundary by naming the date rather than adding a duration. +setClockAt :: TestClock -> UTCTime -> IO () +setClockAt (TestClock r) t = getCurrentTime >>= \real -> writeIORef r (diffUTCTime t real) + +-- | Everything a badge test may need from a running service. +data BadgeServiceEnv = BadgeServiceEnv + { bsIssuerKey :: BadgeIssuerKey, + bsClock :: TestClock, + bsClientCfg :: ChatConfig, + bsAddress :: String, + bsController :: ChatController + } + -- | Start the badge service on a fresh issuer key, and hand the test body what depends on it: -- the client config trusting that key and addressing the service, the address, and the controller. withBadgeService :: HasCallStack => TestParams -> (ChatConfig -> String -> ChatController -> IO ()) -> IO () -withBadgeService ps test = do +withBadgeService ps test = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsAddress, bsController} -> test bsClientCfg bsAddress bsController + +withBadgeServiceEnv :: HasCallStack => TestParams -> (BadgeServiceEnv -> IO ()) -> IO () +withBadgeServiceEnv ps test = do Right (pk, sk) <- bbsKeyGen + clock <- newTestClock let opts = mkBadgeServiceOpts ps sk -- the service refuses to start unless its secret is the key trusted at its index - svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk} + svcCfg = testCfg {badgePublicKeys = M.singleton testIssuerKeyIdx pk, badgeCurrentTime = testClockTime clock} withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure () -- First start: badge service takes the CreateMyAddress branch. runBadgeService svcCfg opts $ \_ -> pure () @@ -97,7 +169,7 @@ withBadgeService ps test = do -- Second start: badge service takes the ShowMyAddress branch, then serves the test body. runBadgeService svcCfg opts $ \env -> do cc <- atomically $ readTMVar $ serviceCC env - test clientCfg bsLink cc + test BadgeServiceEnv {bsIssuerKey = BadgeIssuerKey {keyIdx = testIssuerKeyIdx, secretKey = sk}, bsClock = clock, bsClientCfg = clientCfg, bsAddress = bsLink, bsController = cc} -- through the operator command the service actually exposes, not the function behind it issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode @@ -108,11 +180,15 @@ issueCode cc badgeType months = _ -> error $ "unexpected issue response: " <> T.unpack response r -> error $ "issue failed: " <> show (() <$ r) +-- | The post-start hook fills serviceCC once the address exists, so waiting on it is the service +-- being ready. A fixed delay here raced with startup and left the address output of one start +-- arriving during the next test. runBadgeService :: ChatConfig -> BadgeServiceOpts -> (ServiceState -> IO ()) -> IO () runBadgeService cfg opts action = do env <- newServiceState t <- forkIO $ badgeService opts cfg env - threadDelay 500000 + ready <- timeout 30000000 $ atomically $ readTMVar $ serviceCC env + when (isNothing ready) $ killThread t >> error "badge service did not start" action env `finally` killThread t codeArg :: BadgeCode -> String @@ -225,8 +301,8 @@ issueRaw cc args = Right CRCustomChatResponse {} -> pure $ Right () _ -> pure $ Left () --- Both purchases fund no payment, so both leave payment_id NULL under UNIQUE(payment_id). --- The first purchase stays as it is: retiring a superseded badge is not implemented. +-- The guard is the badge on the profile, so it refuses whatever the code is and whichever type it +-- funds; nothing is sent, so the refused code is still redeemable. testRedeemSecondCode :: HasCallStack => TestParams -> IO () testRedeemSecondCode ps = withBadgeService ps $ \clientCfg _ cc -> @@ -238,16 +314,15 @@ testRedeemSecondCode ps = alice <## "supporter badge - active" alice <##. "expires " alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) + alice <## "bad chat command: badge already active" + alice ##> "/p" + showActiveUser alice "alice (Alice, * supporter)" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> ("/_redeem_badge_code 2 " <> codeArg legend) alice <## "badge redeemed" alice <## "legend badge - active" alice <##. "expires " - alice ##> "/p" - showActiveUser alice "alice (Alice, * legend)" - -- replaying the first code must not put supporter back - alice ##> ("/_redeem_badge_code 1 " <> codeArg supporter) - alice <## "badge already redeemed" - alice ##> "/p" - showActiveUser alice "alice (Alice, * legend)" -- Each profile stashes its own keys, so the second reaches the service as a different signer - -- rather than being handed the first profile's badge, or colliding in badge_code_redemptions. @@ -269,6 +344,705 @@ testRedeemSameCodeOtherProfile ps = alice ##> "/user alice" showActiveUser alice "alice (Alice, * supporter)" +-- Ledger behaviour, driven against the real service - real signing, real rows - through the +-- handler rather than the transport, so the typed response can be asserted. The transport and its +-- purchaseKey guard are covered by the tests above. + +serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse +serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request = + badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject + where + reqObject = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of + J.Object o -> o + _ -> error "badge service request must encode as an object" + +-- the fields a ledger assertion reads, without the ambiguity of the shared record names +entryOf :: StatementEntry -> (Int, Int, UTCTime) +entryOf StatementEntry {changeMonths, balanceMonths, balanceStartTs} = (changeMonths, balanceMonths, balanceStartTs) + +anchorOf :: StatementEntry -> UTCTime +anchorOf StatementEntry {balanceAnchorTs} = balanceAnchorTs + +entryTag :: StatementEntry -> Text +entryTag StatementEntry {entryType} = case entryType of + SECredit c -> creditTypeTag c + SEDebit d -> debitTypeTag d + +statementOf :: HasCallStack => BadgeServiceResponse -> ([StatementEntry], Maybe Text) +statementOf = \case + BSPBadgeCredential {statement = BadgeStatement {entries, previousEntryId}} -> (entries, previousEntryId) + r -> error $ "expected badgeCredential, got " <> show (J.toJSON r) + +credentialOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeCredential +credentialOf = \case + BSPBadgeCredential {credential} -> credential + r -> error $ "expected badgeCredential, got " <> show (J.toJSON r) + +-- the next month falls due when the balance start reaches it, which is the last entry's start +nextDue :: [StatementEntry] -> UTCTime +nextDue entries = let (_, _, start) = entryOf (last entries) in start + +newPurchaseKeys :: IO (C.PublicKeyEd25519, BadgeMasterKey) +newPurchaseKeys = do + g <- C.newRandom + (purchaseKey, _) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) + (purchaseKey,) <$> generateMasterKey g + +assertBalance :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> StatementEntry -> IO BadgeServiceResponse +assertBalance env purchaseKey lastEntry = + serviceCmd env purchaseKey BSCIssueBadge {balance = BadgeBalance {lastEntry}} + +expiryOf :: HasCallStack => BadgeServiceResponse -> Maybe UTCTime +expiryOf r = (\(BadgeCredential _ _ _ BadgeInfo {badgeExpiry}) -> badgeExpiry) <$> credentialOf r + +masterKeyOf :: HasCallStack => BadgeServiceResponse -> Maybe BadgeMasterKey +masterKeyOf r = (\(BadgeCredential _ mk _ _) -> mk) <$> credentialOf r + +-- A three month code credits three months and issues the first; a month later the second is +-- issued, and only then. Asserted against the service's own rows. +testCodeMonthsRenew :: HasCallStack => TestParams -> IO () +testCodeMonthsRenew ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 3 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, previousEntryId) = statementOf redeemed + previousEntryId `shouldBe` Nothing + map entryTag entries `shouldBe` ["code", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries `shouldBe` [(3, 3), (-1, 2)] + credentialOf redeemed `shouldSatisfy` isJust + let firstDue = nextDue entries + -- still inside the first month: nothing new is written + r2 <- assertBalance env purchaseKey (last entries) + map entryTag (fst $ statementOf r2) `shouldBe` [] + -- the second month falls due + setClockAt bsClock firstDue + r3 <- assertBalance env purchaseKey (last entries) + let (entries3, prev3) = statementOf r3 + prev3 `shouldBe` Just (entryIdOf $ last entries) + map entryTag entries3 `shouldBe` ["badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries3 `shouldBe` [(-1, 1)] + credentialOf r3 `shouldSatisfy` isJust + -- a different credential for a different month, not the same signature returned twice + credentialOf r3 `shouldNotBe` credentialOf redeemed + where + entryIdOf StatementEntry {entryId} = entryId + +-- A repeat inside an issued month returns the credential already stored, and writes no row: +-- re-signing the same period would churn the client's credential for nothing. +testRepeatInsideIssuedPeriod :: HasCallStack => TestParams -> IO () +testRepeatInsideIssuedPeriod ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + repeated <- assertBalance env purchaseKey (last entries) + map entryTag (fst $ statementOf repeated) `shouldBe` [] + credentialOf repeated `shouldBe` credentialOf redeemed + +-- Months that passed unissued are lapsed in one row, and the month now current is issued. +testLapseWhileAway :: HasCallStack => TestParams -> IO () +testLapseWhileAway ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 6 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + -- away past the fourth boundary of the run: three months lapse, the fourth is due. Counted + -- from the anchor - adding months to an already clipped due date would miss the boundary. + setClockAt bsClock (addMonths 4 (anchorOf (last entries))) + away <- assertBalance env purchaseKey (last entries) + let (entries', _) = statementOf away + map entryTag entries' `shouldBe` ["lapse", "badge"] + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-3, 2), (-1, 1)] + credentialOf away `shouldSatisfy` isJust + +-- Every badge issued in a week expires at the same moment, so the expiry says nothing about when +-- it was bought. On the last month of a balance paidThrough is the period end exactly, so a +-- client-proposed expiry capped the rounding away and put the expiry back on the anniversary. +testLastMonthExpiryRounds :: HasCallStack => TestParams -> IO () +testLastMonthExpiryRounds ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + let (entries, _) = statementOf redeemed + setClockAt bsClock (nextDue entries) + renewed <- assertBalance env purchaseKey (last entries) + let (entries', _) = statementOf renewed + map entryTag entries' `shouldBe` ["badge"] + -- the last month the balance funds: nothing is left to issue after it + map (\e -> let (c, m, _) = entryOf e in (c, m)) entries' `shouldBe` [(-1, 0)] + expiryOf renewed `shouldBe` Just (endOfMondayAfter (nextDue entries')) + +-- A renewal states nothing, so the credential carries the master key stored with the purchase. +-- The service used to sign whatever key the request supplied, checking only the badge type. +testRenewalSignsWithStoredMasterKey :: HasCallStack => TestParams -> IO () +testRenewalSignsWithStoredMasterKey ps = + withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsController = cc} -> do + code <- issueCode cc BTSupporter 2 + (purchaseKey, masterKey) <- newPurchaseKeys + redeemed <- serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code} + masterKeyOf redeemed `shouldBe` Just masterKey + let (entries, _) = statementOf redeemed + setClockAt bsClock (nextDue entries) + renewed <- assertBalance env purchaseKey (last entries) + masterKeyOf renewed `shouldBe` Just masterKey + +-- The replicated columns of a ledger, in order. service_created_at and created_at are left out: +-- the client records when it stored a row, which is not when the service wrote it. +type ReplicatedRow = (Text, Int, Int, UTCTime, Text, Maybe Text) + +ledgerRows :: ChatController -> String -> IO [ReplicatedRow] +ledgerRows ChatController {chatStore} table = + withTransaction chatStore $ \db -> + DB.query_ db . fromString $ + "SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, " + <> "COALESCE(entry_credit_type, entry_debit_type) FROM " + <> table + <> " ORDER BY entry_id" + +-- The client copies the statement verbatim and authors nothing, so after a redemption both sides +-- hold the same rows under the same entry ids. +testClientReplicatesLedger :: HasCallStack => TestParams -> IO () +testClientReplicatesLedger ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + clientLedger <- ledgerRows (chatController alice) "badge_ledger" + -- the code credit and the first month, on both sides + map (\(_, ch, m, _, _, t) -> (ch, m, t)) serviceLedger `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] + clientLedger `shouldBe` serviceLedger + -- redeeming again replays the statement, and must not duplicate a single row + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge already redeemed" + clientLedger' <- ledgerRows (chatController alice) "badge_ledger" + clientLedger' `shouldBe` serviceLedger + -- nor a second issuance for the one month issued: the replay names a month already stored + expiries <- issuedExpiries (chatController alice) + length expiries `shouldBe` 1 + +-- the balance start of the last row, which is when the next month falls due +dueAtOf :: [ReplicatedRow] -> UTCTime +dueAtOf rows = let (_, _, _, start, _, _) = last rows in start + +-- | The two clock positions a renewal needs: the request, a day before the shown credential +-- lapses, and the presentation, as it lapses. +renewalMoments :: [ReplicatedRow] -> (UTCTime, UTCTime) +renewalMoments rows = + let expiry = endOfMondayAfter $ dueAtOf rows + in (addUTCTime (-nominalDay) expiry, expiry) + +-- | Real seconds between arming a wake and it firing. Long enough for the arming pass to finish +-- first, since one that overruns does the work itself and the test passes without a wake at all. +badgeWakeMargin :: NominalDiffTime +badgeWakeMargin = 3 + +-- | Stand the clock just short of t and signal once. A sleeping worker cannot see the clock move, +-- so the signal is what makes it re-derive - and, standing short, it arms the wake at t rather +-- than doing the work. Whatever follows is produced by that wake. +armWakeAt :: HasCallStack => TestCC -> TestClock -> UTCTime -> IO () +armWakeAt cc clock t = do + setClockAt clock $ addUTCTime (negate badgeWakeMargin) t + cc ##> "/_app activate" + cc <## "ok" + +issuedExpiries :: ChatController -> IO [UTCTime] +issuedExpiries ChatController {chatStore} = do + rows :: [(UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end" + pure $ map fst rows + +peerBadgeExpiry :: ChatController -> IO (Maybe UTCTime) +peerBadgeExpiry ChatController {chatStore} = do + rows :: [(Maybe UTCTime, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_proof IS NOT NULL ORDER BY contact_profile_id" + pure $ case rows of + ((t, _) : _) -> t + [] -> Nothing + +setBadgeExpiry :: ChatController -> String -> UTCTime -> IO () +setBadgeExpiry ChatController {chatStore} whichBadge t = + withTransaction chatStore $ \db -> + DB.execute db (fromString $ "UPDATE contact_profiles SET badge_expiry = ? WHERE " <> whichBadge <> " IS NOT NULL") (Only t) + +-- | Copy the newest ledger row as an entry of a type this version does not know: the service is +-- deployed ahead of app releases, so a new type first arrives at clients that predate it. +insertUnknownLedgerEntry :: ChatController -> IO () +insertUnknownLedgerEntry ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.execute_ db . fromString $ + "INSERT INTO badge_ledger" + <> " (entry_uuid, badge_purchase_id, change_months, balance_months, balance_start_ts, balance_anchor_ts," + <> " balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type," + <> " entry_type_unknown, entry_type_value)" + <> " SELECT 'unknown-entry', badge_purchase_id, 0, balance_months, balance_start_ts, balance_anchor_ts," + <> " balance_badge_type, service_created_at, created_at, 'credit', 'grant', 1, '{\"type\":\"grant\"}'" + <> " FROM badge_ledger ORDER BY entry_id DESC LIMIT 1" + +-- | The month the profile is showing, and the month last issued. They must agree: a profile left +-- on an earlier month shows contacts a badge the ledger has already replaced. +shownAndIssuedExpiry :: ChatController -> IO (Maybe UTCTime, Maybe UTCTime) +shownAndIssuedExpiry ChatController {chatStore} = withTransaction chatStore $ \db -> do + shown :: [(Maybe UTCTime, Int64)] <- + DB.query_ db "SELECT badge_expiry, contact_profile_id FROM contact_profiles WHERE badge_signature IS NOT NULL ORDER BY contact_profile_id" + issued :: [(Maybe UTCTime, Int64)] <- + DB.query_ db "SELECT expiry, badge_purchase_id FROM badge_issuances ORDER BY period_end DESC LIMIT 1" + pure (firstOf shown, firstOf issued) + where + firstOf = \case + ((t, _) : _) -> t + [] -> Nothing + +waitShownIssued :: HasCallStack => ChatController -> IO () +waitShownIssued cc = loop (100 :: Int) + where + -- both absent would compare equal, so the presence of a badge is asserted separately + loop 0 = shownAndIssuedExpiry cc >>= \(shown, issued) -> do + shown `shouldSatisfy` isJust + shown `shouldBe` issued + loop i = + shownAndIssuedExpiry cc >>= \(shown, issued) -> + if isJust shown && shown == issued then pure () else threadDelay 50000 >> loop (i - 1) + +-- The worker acts on its own schedule, so the test waits for the rows rather than for a response. +waitLedgerRows :: HasCallStack => ChatController -> Int -> IO [ReplicatedRow] +waitLedgerRows cc n = loop (100 :: Int) + where + loop 0 = ledgerRows cc "badge_ledger" >>= \rows -> error $ "expected " <> show n <> " ledger rows, got " <> show (length rows) + loop i = do + rows <- ledgerRows cc "badge_ledger" + if length rows >= n then pure rows else threadDelay 50000 >> loop (i - 1) + +-- the badge the profile shows, which the worker clears when the balance has run out +shownBadgeId :: HasCallStack => ChatController -> IO (Maybe Int64) +shownBadgeId ChatController {chatStore} = do + -- two columns rather than one, so the row type needs no backend-specific Only + rows :: [(Maybe Int64, Int64)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT shown_badge_id, user_id FROM users WHERE user_id = 1" + -- not Nothing on an unexpected shape: waiting for Nothing would then pass without reading it + pure $ case rows of + [(i, _)] -> i + _ -> error $ "expected one users row, got " <> show rows + +-- the occurrence the user answered, which silences that alert and no other +ackedEpisode :: HasCallStack => ChatController -> IO (Maybe Text, Maybe Text) +ackedEpisode ChatController {chatStore} = do + rows :: [(Maybe Text, Maybe Text)] <- + withTransaction chatStore $ \db -> + DB.query_ db "SELECT alert_acked_kind, alert_acked_episode FROM badge_purchases" + pure $ case rows of + [r] -> r + _ -> error $ "expected one badge purchase, got " <> show rows + +waitShownBadge :: HasCallStack => ChatController -> Maybe Int64 -> IO () +waitShownBadge cc expected = loop (100 :: Int) + where + loop 0 = shownBadgeId cc >>= \actual -> actual `shouldBe` expected + loop i = + shownBadgeId cc >>= \actual -> + if actual == expected then pure () else threadDelay 50000 >> loop (i - 1) + +redeemFirstBadge :: HasCallStack => TestCC -> BadgeCode -> IO () +redeemFirstBadge alice code = do + alice ##> ("/_redeem_badge_code 1 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + +-- A credential nears its expiry and the badge renews from the worker's own pass, with no command +-- sent by the app: chat activate only signals it, and the work is derived from stored state. +-- The request and the presentation are a day apart, so each renewal takes two passes. +testWorkerRenews :: HasCallStack => TestParams -> IO () +testWorkerRenews ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) redeemed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] + -- the credential the profile shows is a day from lapsing, while the app is running + let (requestAt, presentAt) = renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + -- the renewal reports itself, no command having asked for it + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- the month is issued but not yet worn: the profile still carries the one it had + (shownEarly, issuedEarly) <- shownAndIssuedExpiry (chatController alice) + shownEarly `shouldNotBe` issuedEarly + -- a day later the held credential lapses and the new month is presented + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + -- the third month too: the state a renewal leaves must support the next one + let (requestAt2, presentAt2) = renewalMoments renewed + setClockAt bsClock requestAt2 + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + renewed2 <- waitLedgerRows (chatController alice) 4 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed2 + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge"), (-1, 0, Just "badge")] + -- the client authored none of them: the service holds exactly the same rows + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + renewed2 `shouldBe` serviceLedger + -- and the profile shows the month last issued, not an earlier one + setClockAt bsClock presentAt2 + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + +-- The request is made by the wake the worker set for itself a day before the credential lapses. +testRequestWakeFires :: HasCallStack => TestParams -> IO () +testRequestWakeFires ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + armWakeAt alice bsClock $ fst $ renewalMoments redeemed + -- the arming pass asked for nothing, so what follows cannot be its doing + ledgerRows (chatController alice) "badge_ledger" >>= (`shouldBe` redeemed) + renewed <- waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + +-- The presentation is made by the wake at the expiry itself, a day after the request that issued +-- the month it presents. +testPresentWakeFires :: HasCallStack => TestParams -> IO () +testPresentWakeFires ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + redeemed <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments redeemed + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + void $ waitLedgerRows (chatController alice) 3 + alice <##. "1: supporter" + armWakeAt alice bsClock presentAt + -- the arming pass presented nothing: the profile still carries the credential it had + shownAndIssuedExpiry (chatController alice) >>= \(shown, issued) -> shown `shouldNotBe` issued + waitShownIssued (chatController alice) + +-- The newest row being of an unknown type must not stop the renewal: it is stored verbatim and +-- read back to be asserted, rather than leaving the client with no entry to assert at all. +testRenewsAfterUnknownEntry :: HasCallStack => TestParams -> IO () +testRenewsAfterUnknownEntry ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + insertUnknownLedgerEntry (chatController alice) + setClockAt bsClock $ fst $ renewalMoments rows + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 4 + alice <##. "1: supporter" + -- the unknown row is asserted, so the service answers with what it does not hold: the whole + -- ledger, which re-stores the two rows already held and adds the month it issued + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (0, 2, Just "grant"), (-1, 1, Just "badge")] + +-- The worker driven by chat start rather than by activate, and the only test where the client is +-- given a lapse row to store: the months that passed while the app was stopped. +testRenewsAfterRestart :: HasCallStack => TestParams -> IO () +testRenewsAfterRestart ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do + rows <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 6 + redeemFirstBadge alice code + ledgerRows (chatController alice) "badge_ledger" + -- the credit row's start is the anchor: a grant on a run that has never lapsed moves neither + let (_, _, _, anchor, _, _) = head rows + -- stopped until past the fourth boundary of the run: three months lapse, the fourth is issued + setClockAt bsClock $ addMonths 4 anchor + withTestChatCfg ps bsClientCfg "alice" $ \alice -> do + renewed <- waitLedgerRows (chatController alice) 4 + alice <##. "1: supporter" + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(6, 6, Just "code"), (-1, 5, Just "badge"), (-3, 2, Just "lapse"), (-1, 1, Just "badge")] + -- the lapse row was replicated rather than authored here + serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" + renewed `shouldBe` serviceLedger + -- a week missed costs only the day between the two steps: one pass does both + waitShownIssued (chatController alice) + +-- When the balance is spent and the last period ends, the badge stops being shown and the profile +-- update reaches contacts - the visible half of "the badge expired". +testWorkerRetiresExpired :: HasCallStack => TestParams -> IO () +testWorkerRetiresExpired ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + alice #> "@bob hi" + bob <# "alice *> hi" + -- bob sees it before it expires + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + rows <- ledgerRows (chatController alice) "badge_ledger" + -- the one month it bought has ended and nothing is left to issue + setClockAt bsClock $ dueAtOf rows + alice ##> "/_app activate" + alice <## "ok" + -- support ended, and the state it changed by retiring the badge carries the same alert + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + -- the profile stops showing it locally + waitShownBadge (chatController alice) Nothing + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + -- The removal travels as a profile update, which prints nothing when only the badge + -- changed (viewContactUpdated compares names and links). The next message shows it + -- arrived: bob's prefix loses the badge marker it carried above. + alice #> "@bob after" + bob <# "alice> after" + -- and the badge is gone from the contact's stored profile, not merely from the prefix + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +-- The credential outlives the balance by up to eight days, because its expiry covers renewal and +-- not entitlement. A worker scheduled only on that expiry would leave the badge worn, and the user +-- unasked to buy again, for the whole of that week - so paidThrough is a wake of its own. +testRetiresWhenEntitlementEnds :: HasCallStack => TestParams -> IO () +testRetiresWhenEntitlementEnds ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + armWakeAt alice bsClock $ dueAtOf rows + -- that pass retired nothing and printed nothing: the badge is still worn and /p says so + alice ##> "/p" + alice <## "user profile: alice (Alice, * supporter)" + alice <## "use /p [] to change it" + -- nothing signals the worker from here, and the credential has days left, so the wake that + -- produces these is the one the worker set for itself at paidThrough + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + waitShownBadge (chatController alice) Nothing + +-- The alert is derived from stored state rather than kept pending, so it is still there after a +-- restart; acknowledging records the occurrence it answered, and the same one is not raised again. +testEndedAlert :: HasCallStack => TestParams -> IO () +testEndedAlert ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> do + endsAt <- withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + let endsAt = dueAtOf rows + setClockAt bsClock endsAt + alice ##> "/_app activate" + alice <## "ok" + -- the pass raises the alert, and reports the state it changed by retiring the badge - which + -- carries the same alert, the alert being part of the state + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + pure endsAt + -- nothing was stored as pending, and the alert is derived again on the next start + withTestChatCfg ps bsClientCfg "alice" $ \alice -> do + alice <##. "badge alert: support_ended " + alice ##> ("/_badge ack 1 1 support_ended off " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) + alice <##. "1: supporter" + ackedEpisode (chatController alice) `shouldReturn` (Just "support_ended", Just (safeDecodeUtf8 $ strEncode endsAt)) + -- acknowledged: the state no longer carries the alert, and no event raises it again + alice ##> "/_badge state 1" + alice <##. "1: supporter" + alice ##> "/_app activate" + alice <## "ok" + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + +-- A worker runs for every profile, not only the one in use, so presenting a renewed badge must not +-- make its profile active - the next message would then be sent from the wrong identity. +testRenewalKeepsActiveProfile :: HasCallStack => TestParams -> IO () +testRenewalKeepsActiveProfile ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- alice's credential is a day from lapsing while alisa is the profile in use + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + renewed <- waitLedgerRows (chatController alice) 3 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- the renewal is reported for alice, and the prefix is there because alice is not active + alice <##. "[user: alice] 1: supporter" + -- presenting is the pass that writes alice's profile, and it is the one that could switch + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + alice ##> "/p" + showActiveUser alice "alisa" + +-- A snooze silences the alert until it lapses, and then it is raised once more, without a restart. +-- A snooze changes nothing else on the purchase, so the occurrence already raised has to count it. +testSnoozedAlertReturns :: HasCallStack => TestParams -> IO () +testSnoozedAlertReturns ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + code <- issueCode cc BTSupporter 1 + redeemFirstBadge alice code + rows <- ledgerRows (chatController alice) "badge_ledger" + let endsAt = dueAtOf rows + setClockAt bsClock endsAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "badge alert: support_ended " + alice <##. "1: supporter" + alice <##. "badge alert: support_ended " + alice ##> ("/_badge ack 1 1 support_ended on " <> T.unpack (safeDecodeUtf8 $ strEncode endsAt)) + alice <##. "1: supporter" + -- the ack signalled the worker, and the pass it ran was silent: /p prints only its own output + alice ##> "/p" + alice <## "user profile: alice (Alice)" + alice <## "use /p [] to change it" + -- the snooze lapses: nothing else about the badge changed, so only the alert is reported + setClockAt bsClock $ addUTCTime (nominalDay + 60) endsAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "badge alert: support_ended " + +-- The worker re-reads the profile each pass. Presenting a renewed badge from a copy captured when +-- the worker started would revert any edit made since and broadcast the profile in its old form. +testRenewalKeepsProfileEdits :: HasCallStack => TestParams -> IO () +testRenewalKeepsProfileEdits ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + -- the profile is edited after the worker started + alice ##> "/p alice Alice Jones" + concurrentlyN_ + [ alice <## "user bio changed to Alice Jones (your 1 contacts are notified)", + bob <## "contact alice updated bio: Alice Jones" + ] + rows <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + renewed <- waitLedgerRows (chatController alice) 3 + map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed + `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (-1, 1, Just "badge")] + -- presenting a day later is the pass that broadcasts the profile + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + -- The renewal's profile update carries the edited bio. Had it carried the profile the + -- worker started with, bob would print a bio change back to "Alice" here, before the + -- message - so the message arriving next is the assertion. + alice #> "@bob after renewal" + bob <# "alice *> after renewal" + bob ##> "/i alice" + bob <## "contact ID: 2" + bob <## "supporter badge - active" + bob <##. "expires " + bob <## "receiving messages via: localhost" + bob <## "sending messages via: localhost" + bob <## "you've shared main profile with this contact" + bob <## "connection not verified, use /code command to see security code" + bob <## "quantum resistant end-to-end encryption" + bob <## currentChatVRangeInfo + +-- Forces the state a crash between the issuance write and the profile write leaves behind: the +-- month is issued, unpresented, and no later pass finds it due. +testPresentationCatchesUp :: HasCallStack => TestParams -> IO () +testPresentationCatchesUp ps = + withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClock, bsClientCfg, bsController = cc} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> + withNewTestChatCfg ps bsClientCfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + code <- issueCode cc BTSupporter 3 + redeemFirstBadge alice code + alice #> "@bob hi" + bob <# "alice *> hi" + rows <- ledgerRows (chatController alice) "badge_ledger" + let (requestAt, presentAt) = renewalMoments rows + setClockAt bsClock requestAt + alice ##> "/_app activate" + alice <## "ok" + alice <##. "1: supporter" + void $ waitLedgerRows (chatController alice) 3 + setClockAt bsClock presentAt + alice ##> "/_app activate" + alice <## "ok" + waitShownIssued (chatController alice) + expiries <- issuedExpiries (chatController alice) + length expiries `shouldBe` 2 + let firstMonth = head expiries + latestMonth = last expiries + -- the renewal's rows are kept; only its presentation is undone, on both sides + setBadgeExpiry (chatController alice) "badge_signature" firstMonth + setBadgeExpiry (chatController bob) "badge_proof" firstMonth + alice ##> "/_app activate" + alice <## "ok" + waitPeerBadgeExpiry (chatController bob) latestMonth + (shown, issued) <- shownAndIssuedExpiry (chatController alice) + shown `shouldBe` Just latestMonth + issued `shouldBe` Just latestMonth + alice #> "@bob after repair" + bob <# "alice *> after repair" + +waitPeerBadgeExpiry :: HasCallStack => ChatController -> UTCTime -> IO () +waitPeerBadgeExpiry cc expected = loop (100 :: Int) + where + loop 0 = peerBadgeExpiry cc >>= \actual -> actual `shouldBe` Just expected + loop i = + peerBadgeExpiry cc >>= \actual -> + if actual == Just expected then pure () else threadDelay 50000 >> loop (i - 1) + testPurchaseKeyMismatch :: HasCallStack => TestParams -> IO () testPurchaseKeyMismatch ps = withBadgeService ps $ \clientCfg bsLink _ -> From 47b499dcc474b8392b1e7fbbf99a18662ce4f3c5 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:42:52 +0000 Subject: [PATCH 11/16] core: change badge code prefix to "SB" (#7478) --- src/Simplex/Chat/Badges/Code.hs | 4 ++-- tests/BadgeTests.hs | 12 ++++++------ tests/Bots/BadgeServiceTests.hs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Simplex/Chat/Badges/Code.hs b/src/Simplex/Chat/Badges/Code.hs index 7e2c380119..5098b24a21 100644 --- a/src/Simplex/Chat/Badges/Code.hs +++ b/src/Simplex/Chat/Badges/Code.hs @@ -3,7 +3,7 @@ -- | Badge redemption codes, shared by the client, the badge service and the checkout site. -- --- A code is "SXB-" and 20 Crockford base32 characters in four groups of five: +-- A code is "SB-" and 20 Crockford base32 characters in four groups of five: -- 19 payload characters and a final check character. -- -- Reading folds the characters the alphabet omits so that a code copied by hand still @@ -56,7 +56,7 @@ groupLength :: Int groupLength = 5 codePrefix :: Text -codePrefix = "SXB" +codePrefix = "SB" -- | The Crockford value of a character, folding the omitted characters onto the digits they -- are mistaken for. diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index e26de4823d..5a826bd948 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -189,9 +189,9 @@ testCodeRoundTrip = do drg <- C.newRandom code <- randomBadgeCode drg let formatted = formatBadgeCode code - T.length formatted `shouldBe` 27 -- SXB-XXXXX-XXXXX-XXXXX-XXXXX - T.take 4 formatted `shouldBe` "SXB-" - T.length (badgeCodeText code) `shouldBe` 23 -- the canonical form drops the separators + T.length formatted `shouldBe` 26 -- SB-XXXXX-XXXXX-XXXXX-XXXXX + T.take 3 formatted `shouldBe` "SB-" + T.length (badgeCodeText code) `shouldBe` 22 -- the canonical form drops the separators parseBadgeCode formatted `shouldBe` Just code parseBadgeCode (badgeCodeText code) `shouldBe` Just code @@ -208,7 +208,7 @@ testCodeNormalisation = do parseBadgeCode folded `shouldBe` parseBadgeCode fixedCode parseBadgeCode fixedCode `shouldNotBe` Nothing where - fixedCode = "SXB-0C0QS-XAQW1-N1VSA-R00Y3" + fixedCode = "SB-0C0QS-XAQW1-N1VSA-R00Y3" ambiguous = \case '1' -> 'I' '0' -> 'O' @@ -223,8 +223,8 @@ testCodeCheckCharacter = do wrong = T.init canonical <> T.singleton (if T.last canonical == 'Z' then 'Y' else 'Z') parseBadgeCode wrong `shouldBe` Nothing parseBadgeCode "" `shouldBe` Nothing - parseBadgeCode "SXB-00000-00000-00000-0000" `shouldBe` Nothing - parseBadgeCode (T.drop 3 canonical) `shouldBe` Nothing + parseBadgeCode "SB-00000-00000-00000-0000" `shouldBe` Nothing + parseBadgeCode (T.drop 2 canonical) `shouldBe` Nothing testCodeHash :: IO () testCodeHash = do diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index a45e8040b0..bf09bbd06c 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -258,13 +258,13 @@ testRedeemUnknownCode ps = alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) alice <## "bad chat command: badge service error: code_invalid" -- a failed check character is refused before anything leaves the device - alice ##> "/_redeem_badge_code 1 SXB-00000-00000-00000-00001" + alice ##> "/_redeem_badge_code 1 SB-00000-00000-00000-00001" alice <## "bad chat command: invalid badge code" -- sent straight to the service, past the client's own check, the two are one answer (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" - redeemDirect alice bsLink redeemPriv "SXB-00000-00000-00000-00001" + redeemDirect alice bsLink redeemPriv "SB-00000-00000-00000-00001" alice <## "service response: {\"code\":\"code_invalid\",\"type\":\"error\"}" -- a signed redeemBadgeCode sent as a raw service request, bypassing the client's own checks From bd97e4df34b3ec0af313699586fd78e215f93759 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:41:23 +0000 Subject: [PATCH 12/16] core: verify the badge ledger sent by the service (#7480) --- plans/2026-09-07-badge-ledger-types.md | 13 +- src/Simplex/Chat/Badges/Ledger.hs | 92 +++++++++++-- src/Simplex/Chat/Library/Commands.hs | 22 +-- src/Simplex/Chat/Store/Badges.hs | 6 +- tests/BadgeTests.hs | 183 +++++++++++++++++++++++++ tests/Bots/BadgeServiceTests.hs | 19 +++ 6 files changed, 302 insertions(+), 33 deletions(-) diff --git a/plans/2026-09-07-badge-ledger-types.md b/plans/2026-09-07-badge-ledger-types.md index 424488ee9e..5f830713a6 100644 --- a/plans/2026-09-07-badge-ledger-types.md +++ b/plans/2026-09-07-badge-ledger-types.md @@ -79,20 +79,17 @@ The client's remaining use, `ledgerPlan` inside `badgeWorkDue`, is removed by th The client authors nothing, so it takes the service's arithmetic on trust — while holding everything needed to check it. -**Where.** In `applyBadgeStatement`, as the rows are stored. That is the one place holding both the arriving entries and the stored tip they follow, and it already runs in one transaction. +**Where.** In `applyBadgeStatement`, as the rows are stored — the one place holding both the arriving entries and the stored tip, in one transaction. The tip is always read, and `previousEntryId` selects nothing: it is the predecessor of the statement's first entry alone, and a replayed first entry is not rewritten, so its verdict is discarded either way. A statement that resets the ledger legitimately does so with an `opening` credit, which is checked without a predecessor. -**What.** One outcome per entry, from one of two rules: +**What.** Re-run the operation the entry claims and compare, rather than restate its arithmetic as rules. Each operation is a total function of a predecessor and a timestamp, and the entry carries the timestamp it was computed with. The predecessor is the previous entry as received, the tip for the first, or `emptyEntry` when there is no tip — what `redeemCode` grants onto, so a ledger's first credit is not a rule of its own. An `opening` credit is the exception: it resets the ledger to the amount it states with no relation to the entry before it (badges-rpc.md), so it is checked against nothing but itself — the months it credits, and the purchase's badge type. -- *Against its predecessor* — the previous entry in the statement, or the stored tip for the first one, whatever `previousEntryId` claims. `balanceMonths` equals the predecessor's plus `changeMonths`; a debit moves `balanceStartTs` forward by exactly the months it consumed, counted from the anchor; a credit either leaves the start alone or restarts the run with `balanceStartTs` and `balanceAnchorTs` equal. -- *Opening*, when there is no predecessor at all: a credit whose `balanceMonths` equals its `changeMonths`, with start and anchor equal. - -Checking the first entry against what the client actually holds is also what catches a statement that follows some other ledger — it fails its arithmetic — so `previousEntryId` needs no separate outcome. +Re-running is what catches over-lapsing: writing off three months when one elapsed adds up against its predecessor, and it empties `balanceMonths` while leaving `paidThrough` untouched, so the badge stops renewing while the ledger still reads as paid up. Two values cannot be re-derived and are bounded instead — a credit's months, of which only the sign is checkable, and `createdAt`, which every recompute is anchored on. A `createdAt` before its predecessor's is the service against its own clock and is marked bad; one beyond the client's clock is two clocks disagreeing, so the row goes unjudged rather than accused. **What happens when it fails: store the row and mark it.** Not refuse. Perks do not depend on the ledger — the credential is signed independently and a receiver verifies that signature — so rejecting a statement would strand a badge the service considers paid while proving nothing. The ledger is the user's record of what was spent, and the useful response to arithmetic that does not add up is to keep it and be able to point at the line. -**The column.** `balance_checked`, per entry — `1` when the entry follows from its predecessor, `0` when it does not, and null when nobody has looked. Nullable, because "can be checked" and "has been checked" are different things: every row has a predecessor to check against, and none has been checked while the check is a stub. Not `verified`, which already means signature verification on profiles and would read as the same thing. +**The column.** `balance_checked`, per entry — `1` when re-running reproduced the row, `0` when something contradicted it, and null when nothing was re-run: an unknown tag, a debit declared but unimplemented, or a timestamp this client's clock cannot corroborate. Marking an unrebuildable type `0` would report a newer service's correct row as broken, so those are held only to what is true of any operation — the months add up, the balance is not negative, coverage does not move backwards. Not `verified`, which already means signature verification on profiles and would read as the same thing. -The check belongs in `Ledger.hs`, beside the arithmetic it verifies — `monthsFromAnchor` is internal there and would otherwise have to be exported to check a debit's start. Stub it to null until it exists; adding the column now is what keeps it out of a migration of its own. +The check belongs in `Ledger.hs`, beside the arithmetic it verifies — `monthsFromAnchor` is internal there and would otherwise have to be exported. The column ships with the rest of the ledger schema, keeping it out of a migration of its own. ## The tests move with the types diff --git a/src/Simplex/Chat/Badges/Ledger.hs b/src/Simplex/Chat/Badges/Ledger.hs index 808f8cb5e3..2f31773d2d 100644 --- a/src/Simplex/Chat/Badges/Ledger.hs +++ b/src/Simplex/Chat/Badges/Ledger.hs @@ -19,23 +19,26 @@ module Simplex.Chat.Badges.Ledger ) where +import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Time.Calendar (addDays, addGregorianMonthsClip, toGregorian) import Data.Time.Calendar.WeekDate (toWeekDate) -import Data.Time.Clock (UTCTime (..)) +import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime) import Simplex.Chat.Badges (BadgeType) import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..)) --- | balanceStartTs is always a whole number of months from the anchor; this is that number. -- The calendar difference overshoots by at most one month, so one comparison settles it. -monthsFromAnchor :: StatementEntry -> Integer -monthsFromAnchor StatementEntry {balanceStartTs, balanceAnchorTs} - | addMonths months balanceAnchorTs <= balanceStartTs = max 0 months +monthsBetween :: UTCTime -> UTCTime -> Integer +monthsBetween from to + | addMonths months from <= to = max 0 months | otherwise = max 0 (months - 1) where - (ay, am, _) = toGregorian (utctDay balanceAnchorTs) - (sy, sm, _) = toGregorian (utctDay balanceStartTs) - months = (sy - ay) * 12 + toInteger (sm - am) + (fy, fm, _) = toGregorian (utctDay from) + (ty, tm, _) = toGregorian (utctDay to) + months = (ty - fy) * 12 + toInteger (tm - fm) + +monthsFromAnchor :: StatementEntry -> Integer +monthsFromAnchor e = monthsBetween (balanceAnchorTs e) (balanceStartTs e) -- | The start of the month that follows n more months of this run. monthAfter :: StatementEntry -> Int -> UTCTime @@ -44,8 +47,12 @@ monthAfter e n = addMonths (monthsFromAnchor e + toInteger n) (balanceAnchorTs e paidThrough :: StatementEntry -> UTCTime paidThrough e = monthAfter e (balanceMonths e) +-- Counted from the anchor: 31 Jan plus a month clips to 28 Feb, and counting on from there would +-- retire the next month three days early. elapsedMonths :: UTCTime -> StatementEntry -> Int -elapsedMonths t e = length $ takeWhile (\m -> monthAfter e m <= t) [1 .. balanceMonths e] +elapsedMonths t e = fromInteger $ max 0 $ min (toInteger $ balanceMonths e) elapsed + where + elapsed = monthsBetween (balanceAnchorTs e) t - monthsFromAnchor e -- | The seed for a purchase with no ledger yet: no months, and a run starting now. emptyEntry :: UTCTime -> BadgeType -> StatementEntry @@ -107,11 +114,68 @@ issueEntry t entryId e@StatementEntry {balanceMonths, balanceStartTs} entryType = SEDebit SDBadge } --- | Pairs each arriving entry with whether its balance follows from the one before it, the stored --- tip standing in for the first one's predecessor. 'Nothing' is "not checked". --- TODO [badges] do the arithmetic. -balanceChecked :: Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)] -balanceChecked _tip = map (\e -> (e, Nothing)) +-- Generous because postdating only writes off a month by crossing a month boundary, which takes +-- days, while a device clock a few minutes slow would otherwise leave every row unverified. +maxCreatedAtSkew :: NominalDiffTime +maxCreatedAtSkew = 60 * 60 + +-- | Each entry is checked by re-running the operation it claims: checking only that its numbers +-- follow from the previous entry would pass a lapse of three months where one elapsed. So 'True' +-- means the service ran these functions, not that it ran the right one. 'Nothing' is "not re-run": +-- no operation rebuilds that type, or its timestamp is not credible. +balanceChecked :: UTCTime -> BadgeType -> Maybe StatementEntry -> [StatementEntry] -> [(StatementEntry, Maybe Bool)] +balanceChecked _ _ _ [] = [] +balanceChecked now badgeType tip entries@(first : _) = zipWith withVerdict (opening : entries) entries + where + -- the purchase's own type, not the statement's: on the seed path nothing else contradicts it + opening = fromMaybe (emptyEntry (createdAt first) badgeType) tip + withVerdict prev e = (e, entryChecked now badgeType prev e) + +entryChecked :: UTCTime -> BadgeType -> StatementEntry -> StatementEntry -> Maybe Bool +entryChecked now badgeType prev e + -- the recompute runs on createdAt, so a stamp our own clock contradicts makes every verdict + -- below meaningless - which is not the same as the row being wrong, and is not marked as it + | postdated = Nothing + | backdated = Just False + | otherwise = case entryType e of + SEDebit SDLapse -> maybe (Just False) matches $ lapseEntry t "" prev + SEDebit SDBadge -> maybe (Just False) matches $ issueEntry t "" prev + SEDebit SDRefund -> uncontradicted + SEDebit SDUpgrade {} -> uncontradicted + SEDebit SDTransferOut {} -> uncontradicted + SEDebit SDSupport -> uncontradicted + SEDebit SDUnknown {} -> uncontradicted + -- an opening credit resets the ledger to the amount it states, with no relation to the entry + -- before it (badges-rpc.md), so it is checked against nothing but itself + SECredit SCOpening -> Just restated + SECredit SCUnknown {} -> uncontradicted + SECredit c + -- grantEntry is given the row's month count, so the check agrees with whatever it claims - + -- including a negative count, which shortens what the user paid for. + -- TODO [badges] a purchase made in the app knows the months it bought; check them here. + | changeMonths e < 0 -> Just False + | otherwise -> matches $ grantEntry t "" (changeMonths e) c prev + where + t = createdAt e + postdated = t > addUTCTime maxCreatedAtSkew now + -- two of the service's own stamps, so no allowance and no doubt about whose clock is wrong. + -- Equal is not behind: a service pass writes its lapse and its issue with one clock reading + backdated = t < createdAt prev + matches = Just . sameBalance e + restated = balanceMonths e == changeMonths e && balanceMonths e >= 0 && balanceBadgeType e == badgeType + uncontradicted + | balanceMonths e /= balanceMonths prev + changeMonths e = Just False + | balanceMonths e < 0 = Just False + | balanceStartTs e < balanceStartTs prev = Just False + | otherwise = Nothing + +sameBalance :: StatementEntry -> StatementEntry -> Bool +sameBalance a b = + balanceMonths a == balanceMonths b + && balanceStartTs a == balanceStartTs b + && balanceAnchorTs a == balanceAnchorTs b + && balanceBadgeType a == balanceBadgeType b + && changeMonths a == changeMonths b -- | The tag stored is the string the service sent, so a type this version does not know is kept -- as received and can be read once it does. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index a468603e77..cd949da2fc 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -59,7 +59,7 @@ import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Crypto.Random (ChaChaDRG) import Simplex.Messaging.Session (SessionVar (..), withGetSessVar') -import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) import qualified Simplex.Chat.Badges.Ledger as L import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..)) import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) @@ -5464,7 +5464,7 @@ badgeErrorRetry = \case -- the same request is sent again on the next pass. 'Left' is a service error, already reported, and -- carries when to try again, since a service error is answered rather than thrown. requestBadgeIssue :: UserId -> UserBadgePurchase -> UTCTime -> CM (Either UTCTime StatementEntry) -requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, purchaseKey, purchasePrivKey, masterKey} now = do +requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchaseKey, purchasePrivKey, masterKey} now = do sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure withEntityLock "badgeIssue" (CLBadgeUser userId) $ do user <- withStore $ \db -> getUser db userId @@ -5482,7 +5482,10 @@ requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, purchaseKey, purcha -- TODO [badges] the statement is applied either way, so a failed verification spends the -- month with nothing to show for it; that needs an alert, not only a line in the log g <- asks random - applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId statement cred_ now + -- read again: now was taken before a lock wait and an untimed request, and the check reads + -- it as the client's clock against the timestamps the service put on the rows + storedAt <- badgeNow + applied <- withStore' $ \db -> applyBadgeStatement db g badgePurchaseId badgeType statement cred_ storedAt unless applied $ eToView $ ChatError $ CEInternalError "issued badge credential has no ledger row to store it against" Right <$> (withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) >>= maybe (throwCmdError "badge ledger has no balance") pure) J.Success BSPError {code, retryAfter} -> do @@ -5541,7 +5544,7 @@ stopBadgeWorkers workers = -- issuance and the profile's badge go in one transaction. Answers the user to tell contacts about, -- which the caller does once the badge lock is released. storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) -storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info) statement = +storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = verifyOwnBadge cred >>= \case Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index" Just False -> throwCmdError "redeemed badge credential does not verify against configured key" @@ -5555,7 +5558,7 @@ storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} -- TODO [badges] retire a previously held badge (user', newBadge, applied) <- withStore $ \db -> do (purchaseId, newBadge) <- liftIO $ createCodeBadgePurchase db user redemption cred now - applied <- liftIO $ applyBadgeStatement db g purchaseId statement (Just cred) now + applied <- liftIO $ applyBadgeStatement db g purchaseId badgeType statement (Just cred) now -- a replay must not put a superseded badge back, or tell every contact again user' <- if newBadge then setUserBadge db user (Just badge) else getUser db userId pure (user', newBadge, applied) @@ -5566,10 +5569,13 @@ storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} -- | Store the statement's rows, then the credential against the badge debit row among them. -- 'False' when that row cannot be found, which the caller reports rather than drop in silence. -applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool -applyBadgeStatement db g purchaseId BadgeStatement {entries} cred_ now = do +applyBadgeStatement :: DB.Connection -> TVar ChaChaDRG -> Int64 -> BadgeType -> BadgeStatement -> Maybe BadgeCredential -> UTCTime -> IO Bool +applyBadgeStatement db g purchaseId badgeType BadgeStatement {entries} cred_ now = do + -- TODO [badges] a service that no longer holds the asserted row re-sends its whole history, which + -- joins onto the tip without following it, and every row of it verifies. The service is to heal + -- and restate as one opening credit instead (badges-rpc.md), which is checked without a tip. tip <- getBadgeLedgerLastEntry db purchaseId - storeBadgeStatement db purchaseId tip entries now + storeBadgeStatement db purchaseId badgeType tip entries now case (,) <$> cred_ <*> issuedEntryId of Nothing -> pure True Just (cred, entryUuid) -> diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index d5cb60d6a9..7da9c5fd72 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -273,9 +273,9 @@ clearShownBadge db User {userId} badgePurchaseId = -- | Verbatim, entry_uuid and type included: the client authors no row, or the two sides stop -- holding the same ledger. DO NOTHING makes a re-applied statement a no-op rather than a throw. -- An entry whose balance does not follow from the one before it is stored and marked, not refused. -storeBadgeStatement :: DB.Connection -> Int64 -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO () -storeBadgeStatement db badgePurchaseId tip entries now = - mapM_ storeEntry $ balanceChecked tip entries +storeBadgeStatement :: DB.Connection -> Int64 -> BadgeType -> Maybe StatementEntry -> [StatementEntry] -> UTCTime -> IO () +storeBadgeStatement db badgePurchaseId badgeType tip entries now = + mapM_ storeEntry $ balanceChecked now badgeType tip entries where storeEntry (StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}, checked) = DB.execute diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 5a826bd948..f49bcff59f 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -58,8 +58,22 @@ badgeTests = do it "credits an exhausted balance from the grant, not from the date it ran out" testGrantAfterExhausted it "does not issue months topped up inside an issued period" testGrantInsideIssuedPeriod it "clips month ends without losing the issued period start" testMonthEndClipping + it "counts the elapsed months of an absurd run in one step" testElapsedFarAnchor it "expires at the end of the Monday after the period" testMondayExpiry it "stores the wire tag of every entry type, and rebuilds each from its stored JSON" testEntryTypeColumns + describe "checking a statement" $ do + it "accepts a chain of entries, each against the one before it" testChecksChain + it "checks the second entry against the first, not against the tip" testChecksAgainstStatement + it "accepts an opening credit with no predecessor, and rejects anything else" testChecksOpening + it "rejects an opening credit naming a badge type the purchase is not for" testChecksOpeningBadgeType + it "accepts an opening credit restating the balance over a tip it does not follow" testChecksOpeningRestatement + it "rejects a lapse writing off months that had not elapsed" testChecksOverLapse + it "rejects a debit whose start or anchor moved" testChecksMovedStart + it "rejects a grant restarting a run the predecessor still funds" testChecksGrantRestart + it "rejects a credit of negative months" testChecksNegativeCredit + it "leaves an entry ahead of the clock unjudged, and rejects one behind the entry it follows" testChecksTimestamps + it "leaves an entry type it cannot derive unchecked, its months still checked" testChecksUnknownType + it "rejects an entry it cannot rebuild whose coverage or months contradict the ledger" testChecksUncheckedInvariants describe "worker retry" $ do it "repeats a failure that can clear on its own, and no other" testRetryClassification it "backs off to the cap" testRetryBackoff @@ -258,6 +272,10 @@ bMonths StatementEntry {balanceMonths} = balanceMonths bStart :: StatementEntry -> UTCTime bStart StatementEntry {balanceStartTs} = balanceStartTs +-- the moment the service claims it wrote the row, which is what the check reads it against +stampedAt :: UTCTime -> StatementEntry -> StatementEntry +stampedAt t e = e {createdAt = t} + -- one service pass: lapse what elapsed, then issue if a month is due, as the service chains them. -- The period an issue covers is the previous entry's balance start to its own, so a run of starts -- is what the period assertions read. @@ -387,6 +405,21 @@ testMonthEndClipping = do fmap bStart (issue (at 2027 2 28) feb) `shouldBe` Just (at 2027 3 31) Nothing -> expectationFailure "January was not issued" +-- The anchor and the month count are the service's, and a run claiming to have started a thousand +-- years ago with maxBound months is answered the same way as any other. +testElapsedFarAnchor :: IO () +testElapsedFarAnchor = do + let far = at 1000 1 10 + now = at 2026 1 10 + elapsed = (2026 - 1000) * 12 + huge = (newBalance far) {balanceMonths = maxBound} + three = (newBalance far) {balanceMonths = 3} + fmap bMonths (lapse now huge) `shouldBe` Just (maxBound - elapsed) + fmap bStart (lapse now huge) `shouldBe` Just now + -- and never writes off more months than the balance holds, however long ago it started + fmap bMonths (lapse now three) `shouldBe` Just 0 + fmap bStart (lapse now three) `shouldBe` Just (at 1000 4 10) + testMondayExpiry :: IO () testMondayExpiry = do -- the end of Monday 13 Apr is Tuesday 14 Apr 00:00 @@ -400,6 +433,156 @@ testMondayExpiry = do expiries `shouldSatisfy` all (\(UTCTime d t) -> t == 0 && (\(_, _, wd) -> wd == 2) (toWeekDate d)) zipWith diffUTCTime expiries periodEnds `shouldSatisfy` all (\d -> d > nominalDay && d <= 8 * nominalDay) +verdicts :: UTCTime -> Maybe StatementEntry -> [StatementEntry] -> [Maybe Bool] +verdicts now tip = map snd . balanceChecked now BTSupporter tip + +testChecksChain :: IO () +testChecksChain = do + let opened = newBalance (at 2026 1 10) + oneMonth = grant (at 2026 1 10) 1 opened + Just spent <- pure $ issue (at 2026 1 10) oneMonth + let granted = grant (at 2026 3 10) 3 spent + rows1 = pass (at 2026 3 10) granted + afterFirst = finalBalance granted rows1 + rows2 = pass (at 2026 5 20) afterFirst + statement = granted : rows1 <> rows2 + -- grant, issue, lapse, issue - the first checked against the stored tip + verdicts (at 2026 5 20) (Just spent) statement `shouldBe` replicate 4 (Just True) + +-- The client stores what it received, and the next row is what the service computed from the row it +-- sent - never from the tip, which that row has already superseded. +testChecksAgainstStatement :: IO () +testChecksAgainstStatement = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just firstRow <- pure $ issue start granted + Just secondRow <- pure $ issue (at 2026 4 10) firstRow + verdicts (at 2026 4 10) (Just granted) [firstRow, secondRow] `shouldBe` [Just True, Just True] + -- against the tip it followed, the second entry does not add up + verdicts (at 2026 4 10) (Just granted) [secondRow] `shouldBe` [Just False] + +-- The seed is what redeemCode grants onto, so the row it authors is the one that verifies here. +testChecksOpening :: IO () +testChecksOpening = do + let t = at 2026 3 10 + opening = grant t 12 (newBalance t) + verdicts t Nothing [opening] `shouldBe` [Just True] + Just issued <- pure $ issue t opening + verdicts t Nothing [issued] `shouldBe` [Just False] + +-- The seed takes the purchase's badge type, not the statement's, so an opening row cannot assert a +-- badge the purchase was never for - the one field on that path with something to check it against. +testChecksOpeningBadgeType :: IO () +testChecksOpeningBadgeType = do + let t = at 2026 3 10 + opening = grant t 12 (newBalance t) + verdicts t Nothing [opening] `shouldBe` [Just True] + verdicts t Nothing [opening {balanceBadgeType = BTLegend}] `shouldBe` [Just False] + +-- An opening credit resets the ledger to the amount it states, so it is the one entry whose +-- balance owes nothing to the row before it - a new device, or history discarded into a balance +-- brought forward. It still cannot state a balance other than the months it credits. +testChecksOpeningRestatement :: IO () +testChecksOpeningRestatement = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + restated = granted {entryType = SECredit SCOpening, changeMonths = 3, balanceMonths = 3} + -- the tip funds two months from April; the opening restates three from March and still holds + Just spent <- pure $ issue start granted + verdicts start (Just spent) [restated] `shouldBe` [Just True] + verdicts start (Just spent) [restated {balanceMonths = 9}] `shouldBe` [Just False] + verdicts start (Just spent) [restated {balanceBadgeType = BTLegend}] `shouldBe` [Just False] + +-- Over-lapsing empties the balance while paidThrough stays where it was: the badge stops renewing +-- and the ledger still reads as paid up. The row is self-consistent with the one before it, so only +-- re-running the lapse against its own timestamp catches it. +testChecksOverLapse :: IO () +testChecksOverLapse = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + Just lapsed <- pure $ lapse (at 2026 5 20) issued + Just overLapsed <- pure $ lapse (at 2026 8 20) issued + verdicts (at 2026 5 20) (Just issued) [lapsed] `shouldBe` [Just True] + verdicts (at 2026 5 20) (Just issued) [stampedAt (at 2026 5 20) overLapsed] `shouldBe` [Just False] + bMonths overLapsed `shouldBe` bMonths lapsed - 1 + paidThrough overLapsed `shouldBe` paidThrough lapsed + -- and a lapse claiming a month before any had elapsed: lapseEntry declines it altogether + verdicts start (Just issued) [stampedAt start lapsed] `shouldBe` [Just False] + +testChecksMovedStart :: IO () +testChecksMovedStart = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + verdicts start (Just granted) [issued] `shouldBe` [Just True] + verdicts start (Just granted) [issued {balanceStartTs = addMonths 1 (bStart issued)}] `shouldBe` [Just False] + verdicts start (Just granted) [issued {balanceAnchorTs = addMonths 1 start}] `shouldBe` [Just False] + +testChecksGrantRestart :: IO () +testChecksGrantRestart = do + let t = at 2026 2 10 + opened = newBalance t + oneMonth = grant t 1 opened + funded = grant t 2 opened + Just spent <- pure $ issue t oneMonth + let restarted = grant (at 2026 6 1) 2 spent + verdicts (at 2026 6 1) (Just spent) [restarted] `shouldBe` [Just True] + -- the same entry after a predecessor with months left is a run moved to a later start + verdicts (at 2026 6 1) (Just funded) [restarted] `shouldBe` [Just False] + +testChecksNegativeCredit :: IO () +testChecksNegativeCredit = do + let t = at 2026 2 10 + funded = grant t 2 (newBalance t) + negativeCredit = grant (at 2026 6 1) (-2) funded + verdicts (at 2026 6 1) (Just funded) [negativeCredit] `shouldBe` [Just False] + -- the sign is what rejects it: the row itself adds up, and the recompute would confirm it + bMonths negativeCredit `shouldBe` bMonths funded - 2 + +testChecksTimestamps :: IO () +testChecksTimestamps = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + -- the two clocks are not the same clock, so a row from just ahead of this one is not evidence + verdicts start (Just granted) [stampedAt (addUTCTime (30 * 60) start) issued] `shouldBe` [Just True] + -- further ahead than that, we cannot tell their clock from ours, so the row is left unjudged + verdicts start (Just granted) [stampedAt (addUTCTime (2 * 3600) start) issued] `shouldBe` [Nothing] + -- behind the row it follows is the service against itself, with no clock of ours in it + verdicts start (Just granted) [stampedAt (at 2026 3 1) issued] `shouldBe` [Just False] + +-- Marking a row this version has no operation for as broken would report a newer service as +-- tampering, which is the opposite of the forward compatibility the rest of this code keeps. +testChecksUnknownType :: IO () +testChecksUnknownType = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + let unknown = issued {entryType = SEDebit SDUnknown {tag = "future", json = KM.empty}} + verdicts start (Just granted) [unknown] `shouldBe` [Nothing] + verdicts start (Just granted) [issued {entryType = SEDebit SDRefund}] `shouldBe` [Nothing] + verdicts start (Just granted) [unknown {balanceMonths = 5}] `shouldBe` [Just False] + verdicts start (Just granted) [stampedAt (at 2026 3 1) unknown] `shouldBe` [Just False] + -- an unknown credit takes the same path: fall through to grantEntry and its negative count, + -- which issued carries, would be rejected instead + let unknownCredit = issued {entryType = SECredit SCUnknown {tag = "future", json = KM.empty}} + verdicts start (Just granted) [unknownCredit] `shouldBe` [Nothing] + +-- A tag with no operation behind it escapes the recompute, leaving only the months identity - which +-- holds while coverage moves back, or while the balance goes into debt. +testChecksUncheckedInvariants :: IO () +testChecksUncheckedInvariants = do + let start = at 2026 3 10 + granted = grant start 3 (newBalance start) + Just issued <- pure $ issue start granted + let shortened = issued {entryType = SEDebit SDRefund, changeMonths = 0, balanceStartTs = addMonths (-1) (bStart issued)} + owing = issued {entryType = SEDebit SDRefund, changeMonths = -3, balanceMonths = -1} + verdicts start (Just issued) [shortened] `shouldBe` [Just False] + paidThrough shortened `shouldBe` addMonths (-1) (paidThrough issued) + verdicts start (Just issued) [owing] `shouldBe` [Just False] + bMonths owing `shouldBe` bMonths issued - 3 + -- A failed renewal is otherwise left until the next chat start or activate, which on a desktop -- left running can be days - long enough for a funded badge to lapse. testRetryClassification :: IO () diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index bf09bbd06c..ab34c792f2 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -41,6 +41,7 @@ import Simplex.Chat.Core (sendChatCmdStr) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Messaging.Agent.Store.Common (withTransaction) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Chat.Types (ChatPeerType (..), Profile (..)) import qualified Simplex.Messaging.Crypto as C @@ -503,6 +504,14 @@ ledgerRows ChatController {chatStore} table = <> table <> " ORDER BY entry_id" +-- | The client's verdict on each row, in ledger order. The service has no such column: it computes +-- the rows rather than checking what someone else computed. +balanceChecks :: ChatController -> IO [Maybe Bool] +balanceChecks ChatController {chatStore} = + withTransaction chatStore $ \db -> + map (fmap unBI . fromOnly) + <$> DB.query_ db "SELECT balance_checked FROM badge_ledger ORDER BY entry_id" + -- The client copies the statement verbatim and authors nothing, so after a redemption both sides -- hold the same rows under the same entry ids. testClientReplicatesLedger :: HasCallStack => TestParams -> IO () @@ -519,6 +528,9 @@ testClientReplicatesLedger ps = -- the code credit and the first month, on both sides map (\(_, ch, m, _, _, t) -> (ch, m, t)) serviceLedger `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge")] clientLedger `shouldBe` serviceLedger + -- and re-ran both operations against them: the credit from the seed, the issue from the credit + checks <- balanceChecks (chatController alice) + checks `shouldBe` [Just True, Just True] -- redeeming again replays the statement, and must not duplicate a single row alice ##> ("/_redeem_badge_code 1 " <> codeArg code) alice <## "badge already redeemed" @@ -760,6 +772,10 @@ testRenewsAfterUnknownEntry ps = -- ledger, which re-stores the two rows already held and adds the month it issued map (\(_, ch, m, _, _, t) -> (ch, m, t)) renewed `shouldBe` [(3, 3, Just "code"), (-1, 2, Just "badge"), (0, 2, Just "grant"), (-1, 1, Just "badge")] + -- the issued row follows the badge row in the statement but the unknown one in the ledger, + -- and verifies all the same - the client cannot see that the service dropped a row + checks <- balanceChecks (chatController alice) + checks `shouldBe` [Just True, Just True, Nothing, Just True] -- The worker driven by chat start rather than by activate, and the only test where the client is -- given a lapse row to store: the months that passed while the app was stopped. @@ -782,6 +798,9 @@ testRenewsAfterRestart ps = -- the lapse row was replicated rather than authored here serviceLedger <- ledgerRows cc "sx_badge_service_badge_ledger" renewed `shouldBe` serviceLedger + -- and re-run: the renewal's two rows against the tip the client held, not against a seed + checks <- balanceChecks (chatController alice) + checks `shouldBe` replicate 4 (Just True) -- a week missed costs only the day between the two steps: one pass does both waitShownIssued (chatController alice) From c2c70fa1149898df28ca0edadf10ddf70397509d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:26:32 +0400 Subject: [PATCH 13/16] core: fix flaky badge presentation catch-up test --- tests/Bots/BadgeServiceTests.hs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index ab34c792f2..3725741d37 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -1042,6 +1042,8 @@ testPresentationCatchesUp ps = length expiries `shouldBe` 2 let firstMonth = head expiries latestMonth = last expiries + -- without this bob can receive the presentation after the updates below and never lose it + waitPeerBadgeExpiry (chatController bob) latestMonth -- the renewal's rows are kept; only its presentation is undone, on both sides setBadgeExpiry (chatController alice) "badge_signature" firstMonth setBadgeExpiry (chatController bob) "badge_proof" firstMonth From 069efad7a22ca5876bca03f4af859507be3fdb3d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:15:34 +0000 Subject: [PATCH 14/16] core: consolidate badges schema (#7513) --- .../BadgeService/Store/Postgres/Migrations.hs | 12 +- .../BadgeService/Store/SQLite/Migrations.hs | 12 +- .../2026-07-31-badges-core-implementation.md | 4 +- plans/2026-07-31-badges-service-schema.sql | 2 +- plans/2026-08-04-badges-mvp-scope.md | 2 +- simplex-chat.cabal | 4 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- ...ser_badges.hs => M20260915_user_badges.hs} | 192 ++++++++++-------- .../Store/Postgres/Migrations/chat_schema.sql | 161 +-------------- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- ...ser_badges.hs => M20260915_user_badges.hs} | 192 ++++++++++-------- .../Store/SQLite/Migrations/chat_schema.sql | 55 +---- 12 files changed, 247 insertions(+), 397 deletions(-) rename src/Simplex/Chat/Store/Postgres/Migrations/{M20261001_user_badges.hs => M20260915_user_badges.hs} (86%) rename src/Simplex/Chat/Store/SQLite/Migrations/{M20261001_user_badges.hs => M20260915_user_badges.hs} (86%) diff --git a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs index 18d589b037..e8b62c2d53 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -6,7 +6,7 @@ module BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) whe import Data.List (sortOn) import Data.Text (Text) -import Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges (badgeSchema, badgeSchemaDown, withPrefix) +import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges (badgeSchema, badgeSchemaDown, withPrefix) import Simplex.Messaging.Agent.Store.Shared (Migration (..)) import Text.RawString.QQ (r) @@ -30,8 +30,6 @@ m20260806_badge_service_schema = <> withPrefix servicePrefix [r| -ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; - CREATE TABLE @badge_codes( badge_code_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code_hash BYTEA NOT NULL, @@ -77,3 +75,11 @@ DROP INDEX @idx_badge_purchases_code; [r| DROP TABLE @badge_codes; |] + +{- TODO [badges] deferred with the draft in M20260915_user_badges, service only. + +ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; + +-- down +ALTER TABLE @payments DROP COLUMN receipt_hash; +-} diff --git a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs index 2851813c8c..ce08db0c7a 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -8,7 +8,7 @@ import Data.List (sortOn) import Data.Text (Text) import Database.SQLite.Simple (Query (..)) import Database.SQLite.Simple.QQ (sql) -import Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges (badgeSchema, badgeSchemaDown, withPrefix) +import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges (badgeSchema, badgeSchemaDown, withPrefix) import Simplex.Messaging.Agent.Store.Shared (Migration (..)) badgeServiceSchemaMigrations :: [Migration] @@ -31,8 +31,6 @@ m20260806_badge_service_schema = <> withPrefix servicePrefix [sql| -ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; - CREATE TABLE @badge_codes( badge_code_id INTEGER PRIMARY KEY AUTOINCREMENT, code_hash BLOB NOT NULL, @@ -78,3 +76,11 @@ DROP INDEX @idx_badge_purchases_code; [sql| DROP TABLE @badge_codes; |] + +{- TODO [badges] deferred with the draft in M20260915_user_badges, service only. + +ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; + +-- down +ALTER TABLE @payments DROP COLUMN receipt_hash; +-} diff --git a/plans/2026-07-31-badges-core-implementation.md b/plans/2026-07-31-badges-core-implementation.md index c8f45deb2d..f36e1f8718 100644 --- a/plans/2026-07-31-badges-core-implementation.md +++ b/plans/2026-07-31-badges-core-implementation.md @@ -7,7 +7,7 @@ ## 1. Client schema -`src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs` — SQLite only; the Postgres variant is written when the schema is final; registered in the migrations list and cabal at delivery step 2. +`src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs` — SQLite only; the Postgres variant is written when the schema is final; registered in the migrations list and cabal at delivery step 2. - Table mapping to UX §3: - `badges` → `badge_purchases` @@ -401,7 +401,7 @@ Each UX plan point and its implementation home: ## Delivery order 1. `mkBadgeStatus`: the +7-day recipient display grace and the shifted `BSExpiredOld` boundary (UX 2.11) — released before purchases (UX §7). -2. Register migration `M20261001_user_badges` (migrations list + cabal + regenerated `chat_schema.sql` / `chat_lint.sql`); the Postgres variant of the migration; the catalog seed from app config (UX §3 prices); store functions (`Store/Badges.hs`): get-or-create with lock; last-ledger-row reads; verbatim replica inserts. +2. Register migration `M20260915_user_badges` (migrations list + cabal + regenerated `chat_schema.sql` / `chat_lint.sql`); the Postgres variant of the migration; the catalog seed from app config (UX §3 prices); store functions (`Store/Badges.hs`): get-or-create with lock; last-ledger-row reads; verbatim replica inserts. 3. Instances for the types in `Badges.hs` and `Badges/Service.hs` (§3) + roundtrip tests. 4. RPC codec: `docs/protocol/badges-rpc.schema.json` packets ↔ `Badges/Service.hs` types; catalog reconciliation on every `badgeCatalog` response. 5. BadgeManager §6: worker, locks, timer; reconcile/apply/presentation/alert steps; events. diff --git a/plans/2026-07-31-badges-service-schema.sql b/plans/2026-07-31-badges-service-schema.sql index 44bb1c6d00..a98e31c6b7 100644 --- a/plans/2026-07-31-badges-service-schema.sql +++ b/plans/2026-07-31-badges-service-schema.sql @@ -81,7 +81,7 @@ CREATE TABLE charges( UNIQUE(payment_id, provider_charge_ref) ); --- badgeLedgerTable constant (M20261001_user_badges), re-used literally +-- badgeLedgerTable constant (M20260915_user_badges), re-used literally CREATE TABLE badge_ledger( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, entry_uuid TEXT NOT NULL, diff --git a/plans/2026-08-04-badges-mvp-scope.md b/plans/2026-08-04-badges-mvp-scope.md index d41613a00e..26a4fc3081 100644 --- a/plans/2026-08-04-badges-mvp-scope.md +++ b/plans/2026-08-04-badges-mvp-scope.md @@ -28,7 +28,7 @@ Order: one path end to end first, then the paths that need external systems. Eac - Store functions (`Store/Badges.hs`): get-or-create under the user lock, last-entry reads, verbatim replica writes. - `BadgeManager`: worker, locks, reconcile, apply, presentation. The timer and `CEvtBadgeAlert` follow later. - JSON instances for `Badges/Service.hs` and `Badges/Store.hs` — `taggedObjectJSON`, with roundtrip tests. -- Register `M20261001_user_badges` in the migrations list and cabal; regenerate `chat_schema.sql` and `chat_lint.sql`. +- Register `M20260915_user_badges` in the migrations list and cabal; regenerate `chat_schema.sql` and `chat_lint.sql`. ## 3. Store integration — iOS, Android diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 54debb76f5..9563684749 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -165,7 +165,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges - Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges + Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges else exposed-modules: Simplex.Chat.Archive @@ -340,7 +340,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges - Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges + Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 3815c90367..9e5a1aa5aa 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invita import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges -import Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges +import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -101,7 +101,7 @@ schemaMigrations = ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), - ("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges) + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs similarity index 86% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs index 1e8042160c..98bdd53db7 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260915_user_badges.hs @@ -1,7 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges where +module Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges where import Data.Text (Text) import qualified Data.Text as T @@ -58,18 +58,6 @@ CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); -CREATE TABLE @subscription_charges( - charge_id TEXT NOT NULL PRIMARY KEY, - payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, - provider_charge_ref TEXT NOT NULL, - period_start TIMESTAMPTZ NOT NULL, - period_end TIMESTAMPTZ NOT NULL, - amount BIGINT NOT NULL, - currency TEXT NOT NULL, - charged_at TIMESTAMPTZ NOT NULL, - UNIQUE(payment_id, provider_charge_ref) -); - CREATE TABLE @badge_prices( price_id TEXT NOT NULL PRIMARY KEY, badge_type TEXT NOT NULL, @@ -97,14 +85,90 @@ CREATE TABLE @badge_purchases( master_key BYTEA NOT NULL, initial_badge_type TEXT NOT NULL, current_badge_type TEXT NOT NULL, - payment_id TEXT REFERENCES @payments, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(purchase_key), - UNIQUE(payment_id) + UNIQUE(purchase_key) ); +CREATE TABLE @badge_ledger( + entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entry_uuid TEXT NOT NULL, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + change_months SMALLINT NOT NULL, + balance_months SMALLINT NOT NULL, + balance_start_ts TIMESTAMPTZ NOT NULL, + balance_anchor_ts TIMESTAMPTZ NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TIMESTAMPTZ, + service_created_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT +); + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id BIGINT REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + expiry TIMESTAMPTZ NOT NULL, + credential BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); +|] + +badgeSchemaTablesDown :: Text +badgeSchemaTablesDown = + [r| +DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; +DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP TABLE @badge_ledger; +DROP TABLE @badge_purchases; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +{- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. + +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + amount BIGINT NOT NULL, + currency TEXT NOT NULL, + charged_at TIMESTAMPTZ NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -136,30 +200,13 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -CREATE TABLE @badge_ledger( - entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - entry_uuid TEXT NOT NULL, - badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, - change_months SMALLINT NOT NULL, - balance_months SMALLINT NOT NULL, - balance_start_ts TIMESTAMPTZ NOT NULL, - balance_anchor_ts TIMESTAMPTZ NOT NULL, - balance_badge_type TEXT NOT NULL, - was_paused_since TIMESTAMPTZ, - service_created_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - entry_type TEXT NOT NULL, - entry_credit_type TEXT, - entry_debit_type TEXT, - payment_id TEXT REFERENCES @payments, - charge_id TEXT REFERENCES @subscription_charges, - from_purchase_id BIGINT REFERENCES @badge_purchases, - to_purchase_id BIGINT REFERENCES @badge_purchases -); +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; -CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; -CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); +ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id BIGINT REFERENCES @badge_purchases; + +ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id BIGINT REFERENCES @badge_purchases; CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); @@ -169,58 +216,28 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -CREATE TABLE @badge_issuances( - issuance_id TEXT NOT NULL PRIMARY KEY, - badge_purchase_id BIGINT NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, - entry_id BIGINT REFERENCES @badge_ledger, - badge_type TEXT NOT NULL, - period_start TIMESTAMPTZ NOT NULL, - period_end TIMESTAMPTZ NOT NULL, - expiry TIMESTAMPTZ NOT NULL, - credential BYTEA NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); - -CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); - -CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); -|] - -badgeSchemaTablesDown :: Text -badgeSchemaTablesDown = - [r| -DROP INDEX @idx_badge_issuances_purchase; -DROP INDEX @idx_badge_issuances_entry; -DROP INDEX @idx_badge_issuances_purchase_entry; -DROP TABLE @badge_issuances; -DROP INDEX @idx_badge_ledger_uuid; -DROP INDEX @idx_badge_ledger_purchase; +-- down DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -DROP TABLE @badge_ledger; +ALTER TABLE @badge_ledger DROP COLUMN payment_id; +ALTER TABLE @badge_ledger DROP COLUMN charge_id; +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP TABLE @badge_purchases; +DROP INDEX @idx_badge_purchases_payment; +ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -DROP INDEX @idx_payments_provider_ref; -DROP INDEX @idx_payments_invoice; -DROP TABLE @payments; -DROP TABLE @invoices; -DROP INDEX @idx_badge_offers_price; -DROP TABLE @badge_offers; -DROP TABLE @badge_prices; -|] +-} -m20261001_user_badges :: Text -m20261001_user_badges = +m20260915_user_badges :: Text +m20260915_user_badges = badgeSchema "" <> [r| ALTER TABLE badge_purchases ADD COLUMN user_id BIGINT REFERENCES users ON DELETE CASCADE; @@ -233,10 +250,6 @@ ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TIMESTAMPTZ; -ALTER TABLE payments ADD COLUMN evidence BYTEA; - -ALTER TABLE payments ADD COLUMN receipt_code TEXT; - ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown SMALLINT NOT NULL DEFAULT 0; ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; @@ -267,8 +280,8 @@ ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id BIGINT REFERENCE CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] -down_m20261001_user_badges :: Text -down_m20261001_user_badges = +down_m20260915_user_badges :: Text +down_m20260915_user_badges = [r| DROP INDEX idx_badge_purchases_code_redemption; DROP INDEX idx_badge_purchases_user; @@ -280,3 +293,14 @@ ALTER TABLE users DROP COLUMN shown_badge_id; DROP INDEX idx_badge_code_redemptions_user; DROP TABLE badge_code_redemptions; |] + +{- TODO [badges] deferred with the draft above, client only. + +ALTER TABLE payments ADD COLUMN evidence BYTEA; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +-- down +ALTER TABLE payments DROP COLUMN evidence; +ALTER TABLE payments DROP COLUMN receipt_code; +-} diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index b16bb75f51..1e2a26d74e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -191,17 +191,6 @@ ALTER TABLE test_chat_schema.badge_code_redemptions ALTER COLUMN badge_code_rede -CREATE TABLE test_chat_schema.badge_invoices ( - invoice_id text NOT NULL, - badge_purchase_id bigint NOT NULL, - price_id text NOT NULL, - offer_id text, - months smallint NOT NULL, - created_at timestamp with time zone NOT NULL -); - - - CREATE TABLE test_chat_schema.badge_issuances ( issuance_id text NOT NULL, badge_purchase_id bigint NOT NULL, @@ -231,10 +220,6 @@ CREATE TABLE test_chat_schema.badge_ledger ( entry_type text NOT NULL, entry_credit_type text, entry_debit_type text, - payment_id text, - charge_id text, - from_purchase_id bigint, - to_purchase_id bigint, entry_type_unknown smallint DEFAULT 0 NOT NULL, entry_type_value text, balance_checked smallint @@ -282,7 +267,6 @@ CREATE TABLE test_chat_schema.badge_purchases ( master_key bytea NOT NULL, initial_badge_type text NOT NULL, current_badge_type text NOT NULL, - payment_id text, status text NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, @@ -307,22 +291,6 @@ ALTER TABLE test_chat_schema.badge_purchases ALTER COLUMN badge_purchase_id ADD -CREATE TABLE test_chat_schema.badge_subscription_changes ( - change_id text NOT NULL, - badge_purchase_id bigint NOT NULL, - from_badge_type text NOT NULL, - to_badge_type text NOT NULL, - from_provider_ref text, - to_provider_ref text, - effective text NOT NULL, - status text NOT NULL, - effective_at timestamp with time zone, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - - - CREATE TABLE test_chat_schema.calls ( call_id bigint NOT NULL, contact_id bigint NOT NULL, @@ -1378,9 +1346,7 @@ CREATE TABLE test_chat_schema.payments ( grace_until timestamp with time zone, cancelled smallint DEFAULT 0 NOT NULL, created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - evidence bytea, - receipt_code text + updated_at timestamp with time zone NOT NULL ); @@ -1679,19 +1645,6 @@ CREATE TABLE test_chat_schema.snd_files ( -CREATE TABLE test_chat_schema.subscription_charges ( - charge_id text NOT NULL, - payment_id text NOT NULL, - provider_charge_ref text NOT NULL, - period_start timestamp with time zone NOT NULL, - period_end timestamp with time zone NOT NULL, - amount bigint NOT NULL, - currency text NOT NULL, - charged_at timestamp with time zone NOT NULL -); - - - CREATE TABLE test_chat_schema.usage_conditions ( usage_conditions_id bigint NOT NULL, conditions_commit text NOT NULL, @@ -1804,11 +1757,6 @@ ALTER TABLE ONLY test_chat_schema.badge_code_redemptions -ALTER TABLE ONLY test_chat_schema.badge_invoices - ADD CONSTRAINT badge_invoices_pkey PRIMARY KEY (invoice_id); - - - ALTER TABLE ONLY test_chat_schema.badge_issuances ADD CONSTRAINT badge_issuances_pkey PRIMARY KEY (issuance_id); @@ -1829,11 +1777,6 @@ ALTER TABLE ONLY test_chat_schema.badge_prices -ALTER TABLE ONLY test_chat_schema.badge_purchases - ADD CONSTRAINT badge_purchases_payment_id_key UNIQUE (payment_id); - - - ALTER TABLE ONLY test_chat_schema.badge_purchases ADD CONSTRAINT badge_purchases_pkey PRIMARY KEY (badge_purchase_id); @@ -1844,11 +1787,6 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases -ALTER TABLE ONLY test_chat_schema.badge_subscription_changes - ADD CONSTRAINT badge_subscription_changes_pkey PRIMARY KEY (change_id); - - - ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_pkey PRIMARY KEY (call_id); @@ -2164,16 +2102,6 @@ ALTER TABLE ONLY test_chat_schema.snd_files -ALTER TABLE ONLY test_chat_schema.subscription_charges - ADD CONSTRAINT subscription_charges_payment_id_provider_charge_ref_key UNIQUE (payment_id, provider_charge_ref); - - - -ALTER TABLE ONLY test_chat_schema.subscription_charges - ADD CONSTRAINT subscription_charges_pkey PRIMARY KEY (charge_id); - - - ALTER TABLE ONLY test_chat_schema.usage_conditions ADD CONSTRAINT usage_conditions_conditions_commit_key UNIQUE (conditions_commit); @@ -2230,18 +2158,6 @@ CREATE INDEX idx_badge_code_redemptions_user ON test_chat_schema.badge_code_rede -CREATE INDEX idx_badge_invoices_offer ON test_chat_schema.badge_invoices USING btree (offer_id); - - - -CREATE INDEX idx_badge_invoices_price ON test_chat_schema.badge_invoices USING btree (price_id); - - - -CREATE INDEX idx_badge_invoices_purchase ON test_chat_schema.badge_invoices USING btree (badge_purchase_id); - - - CREATE INDEX idx_badge_issuances_entry ON test_chat_schema.badge_issuances USING btree (entry_id); @@ -2254,26 +2170,10 @@ CREATE UNIQUE INDEX idx_badge_issuances_purchase_entry ON test_chat_schema.badge -CREATE INDEX idx_badge_ledger_charge ON test_chat_schema.badge_ledger USING btree (charge_id); - - - -CREATE INDEX idx_badge_ledger_from_purchase ON test_chat_schema.badge_ledger USING btree (from_purchase_id); - - - -CREATE INDEX idx_badge_ledger_payment ON test_chat_schema.badge_ledger USING btree (payment_id); - - - CREATE INDEX idx_badge_ledger_purchase ON test_chat_schema.badge_ledger USING btree (badge_purchase_id, entry_id); -CREATE INDEX idx_badge_ledger_to_purchase ON test_chat_schema.badge_ledger USING btree (to_purchase_id); - - - CREATE UNIQUE INDEX idx_badge_ledger_uuid ON test_chat_schema.badge_ledger USING btree (entry_uuid); @@ -2290,10 +2190,6 @@ CREATE INDEX idx_badge_purchases_user ON test_chat_schema.badge_purchases USING -CREATE INDEX idx_badge_subscription_changes_purchase ON test_chat_schema.badge_subscription_changes USING btree (badge_purchase_id); - - - CREATE INDEX idx_calls_chat_item_id ON test_chat_schema.calls USING btree (chat_item_id); @@ -3067,26 +2963,6 @@ ALTER TABLE ONLY test_chat_schema.badge_code_redemptions -ALTER TABLE ONLY test_chat_schema.badge_invoices - ADD CONSTRAINT badge_invoices_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; - - - -ALTER TABLE ONLY test_chat_schema.badge_invoices - ADD CONSTRAINT badge_invoices_invoice_id_fkey FOREIGN KEY (invoice_id) REFERENCES test_chat_schema.invoices(invoice_id) ON DELETE CASCADE; - - - -ALTER TABLE ONLY test_chat_schema.badge_invoices - ADD CONSTRAINT badge_invoices_offer_id_fkey FOREIGN KEY (offer_id) REFERENCES test_chat_schema.badge_offers(offer_id); - - - -ALTER TABLE ONLY test_chat_schema.badge_invoices - ADD CONSTRAINT badge_invoices_price_id_fkey FOREIGN KEY (price_id) REFERENCES test_chat_schema.badge_prices(price_id); - - - ALTER TABLE ONLY test_chat_schema.badge_issuances ADD CONSTRAINT badge_issuances_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; @@ -3102,26 +2978,6 @@ ALTER TABLE ONLY test_chat_schema.badge_ledger -ALTER TABLE ONLY test_chat_schema.badge_ledger - ADD CONSTRAINT badge_ledger_charge_id_fkey FOREIGN KEY (charge_id) REFERENCES test_chat_schema.subscription_charges(charge_id); - - - -ALTER TABLE ONLY test_chat_schema.badge_ledger - ADD CONSTRAINT badge_ledger_from_purchase_id_fkey FOREIGN KEY (from_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id); - - - -ALTER TABLE ONLY test_chat_schema.badge_ledger - ADD CONSTRAINT badge_ledger_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id); - - - -ALTER TABLE ONLY test_chat_schema.badge_ledger - ADD CONSTRAINT badge_ledger_to_purchase_id_fkey FOREIGN KEY (to_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id); - - - ALTER TABLE ONLY test_chat_schema.badge_offers ADD CONSTRAINT badge_offers_price_id_fkey FOREIGN KEY (price_id) REFERENCES test_chat_schema.badge_prices(price_id); @@ -3132,21 +2988,11 @@ ALTER TABLE ONLY test_chat_schema.badge_purchases -ALTER TABLE ONLY test_chat_schema.badge_purchases - ADD CONSTRAINT badge_purchases_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id); - - - ALTER TABLE ONLY test_chat_schema.badge_purchases ADD CONSTRAINT badge_purchases_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; -ALTER TABLE ONLY test_chat_schema.badge_subscription_changes - ADD CONSTRAINT badge_subscription_changes_badge_purchase_id_fkey FOREIGN KEY (badge_purchase_id) REFERENCES test_chat_schema.badge_purchases(badge_purchase_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -3802,11 +3648,6 @@ ALTER TABLE ONLY test_chat_schema.snd_files -ALTER TABLE ONLY test_chat_schema.subscription_charges - ADD CONSTRAINT subscription_charges_payment_id_fkey FOREIGN KEY (payment_id) REFERENCES test_chat_schema.payments(payment_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.user_contact_links ADD CONSTRAINT user_contact_links_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index b4bd47e36d..0bbd7c06b6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -173,7 +173,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitati import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges -import Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges +import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -347,7 +347,7 @@ schemaMigrations = ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), - ("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges) + ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs similarity index 86% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs index 652eaac7ab..3462db9bc1 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20261001_user_badges.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260915_user_badges.hs @@ -1,7 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges where +module Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges where import Data.Text (Text) import qualified Data.Text as T @@ -59,18 +59,6 @@ CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); CREATE INDEX @idx_payments_invoice ON @payments(invoice_id); -CREATE TABLE @subscription_charges( - charge_id TEXT NOT NULL PRIMARY KEY, - payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, - provider_charge_ref TEXT NOT NULL, - period_start TEXT NOT NULL, - period_end TEXT NOT NULL, - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - charged_at TEXT NOT NULL, - UNIQUE(payment_id, provider_charge_ref) -) STRICT; - CREATE TABLE @badge_prices( price_id TEXT NOT NULL PRIMARY KEY, badge_type TEXT NOT NULL, @@ -98,14 +86,90 @@ CREATE TABLE @badge_purchases( master_key BLOB NOT NULL, initial_badge_type TEXT NOT NULL, current_badge_type TEXT NOT NULL, - payment_id TEXT REFERENCES @payments, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - UNIQUE(purchase_key), - UNIQUE(payment_id) + UNIQUE(purchase_key) ) STRICT; +CREATE TABLE @badge_ledger( + entry_id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_uuid TEXT NOT NULL, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + change_months INTEGER NOT NULL, + balance_months INTEGER NOT NULL, + balance_start_ts TEXT NOT NULL, + balance_anchor_ts TEXT NOT NULL, + balance_badge_type TEXT NOT NULL, + was_paused_since TEXT, + service_created_at TEXT NOT NULL, + created_at TEXT NOT NULL, + entry_type TEXT NOT NULL, + entry_credit_type TEXT, + entry_debit_type TEXT +) STRICT; + +CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); + +CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); + +CREATE TABLE @badge_issuances( + issuance_id TEXT NOT NULL PRIMARY KEY, + badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, + entry_id INTEGER REFERENCES @badge_ledger, + badge_type TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + expiry TEXT NOT NULL, + credential BLOB NOT NULL, + created_at TEXT NOT NULL +) STRICT; + +CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); + +CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); + +CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); +|] + +badgeSchemaTablesDown :: Query +badgeSchemaTablesDown = + [sql| +DROP INDEX @idx_badge_issuances_purchase; +DROP INDEX @idx_badge_issuances_entry; +DROP INDEX @idx_badge_issuances_purchase_entry; +DROP TABLE @badge_issuances; +DROP INDEX @idx_badge_ledger_uuid; +DROP INDEX @idx_badge_ledger_purchase; +DROP TABLE @badge_ledger; +DROP TABLE @badge_purchases; +DROP INDEX @idx_payments_provider_ref; +DROP INDEX @idx_payments_invoice; +DROP TABLE @payments; +DROP TABLE @invoices; +DROP INDEX @idx_badge_offers_price; +DROP TABLE @badge_offers; +DROP TABLE @badge_prices; +|] + +{- TODO [badges] deferred draft schema for paid purchases, subscriptions, upgrades and transfers. + +CREATE TABLE @subscription_charges( + charge_id TEXT NOT NULL PRIMARY KEY, + payment_id TEXT NOT NULL REFERENCES @payments ON DELETE CASCADE, + provider_charge_ref TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER NOT NULL, + currency TEXT NOT NULL, + charged_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +) STRICT; + +ALTER TABLE @badge_purchases ADD COLUMN payment_id TEXT REFERENCES @payments; + +CREATE UNIQUE INDEX @idx_badge_purchases_payment ON @badge_purchases(payment_id); + CREATE TABLE @badge_invoices( invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES @invoices ON DELETE CASCADE, badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, @@ -137,30 +201,13 @@ CREATE TABLE @badge_subscription_changes( CREATE INDEX @idx_badge_subscription_changes_purchase ON @badge_subscription_changes(badge_purchase_id); -CREATE TABLE @badge_ledger( - entry_id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_uuid TEXT NOT NULL, - badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, - change_months INTEGER NOT NULL, - balance_months INTEGER NOT NULL, - balance_start_ts TEXT NOT NULL, - balance_anchor_ts TEXT NOT NULL, - balance_badge_type TEXT NOT NULL, - was_paused_since TEXT, - service_created_at TEXT NOT NULL, - created_at TEXT NOT NULL, - entry_type TEXT NOT NULL, - entry_credit_type TEXT, - entry_debit_type TEXT, - payment_id TEXT REFERENCES @payments, - charge_id TEXT REFERENCES @subscription_charges, - from_purchase_id INTEGER REFERENCES @badge_purchases, - to_purchase_id INTEGER REFERENCES @badge_purchases -) STRICT; +ALTER TABLE @badge_ledger ADD COLUMN payment_id TEXT REFERENCES @payments; -CREATE UNIQUE INDEX @idx_badge_ledger_uuid ON @badge_ledger(entry_uuid); +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; -CREATE INDEX @idx_badge_ledger_purchase ON @badge_ledger(badge_purchase_id, entry_id); +ALTER TABLE @badge_ledger ADD COLUMN from_purchase_id INTEGER REFERENCES @badge_purchases; + +ALTER TABLE @badge_ledger ADD COLUMN to_purchase_id INTEGER REFERENCES @badge_purchases; CREATE INDEX @idx_badge_ledger_payment ON @badge_ledger(payment_id); @@ -170,58 +217,28 @@ CREATE INDEX @idx_badge_ledger_from_purchase ON @badge_ledger(from_purchase_id); CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); -CREATE TABLE @badge_issuances( - issuance_id TEXT NOT NULL PRIMARY KEY, - badge_purchase_id INTEGER NOT NULL REFERENCES @badge_purchases ON DELETE CASCADE, - entry_id INTEGER REFERENCES @badge_ledger, - badge_type TEXT NOT NULL, - period_start TEXT NOT NULL, - period_end TEXT NOT NULL, - expiry TEXT NOT NULL, - credential BLOB NOT NULL, - created_at TEXT NOT NULL -) STRICT; - -CREATE INDEX @idx_badge_issuances_purchase ON @badge_issuances(badge_purchase_id, issuance_id); - -CREATE INDEX @idx_badge_issuances_entry ON @badge_issuances(entry_id); - -CREATE UNIQUE INDEX @idx_badge_issuances_purchase_entry ON @badge_issuances(badge_purchase_id, entry_id); -|] - -badgeSchemaTablesDown :: Query -badgeSchemaTablesDown = - [sql| -DROP INDEX @idx_badge_issuances_purchase; -DROP INDEX @idx_badge_issuances_entry; -DROP INDEX @idx_badge_issuances_purchase_entry; -DROP TABLE @badge_issuances; -DROP INDEX @idx_badge_ledger_uuid; -DROP INDEX @idx_badge_ledger_purchase; +-- down DROP INDEX @idx_badge_ledger_payment; DROP INDEX @idx_badge_ledger_charge; DROP INDEX @idx_badge_ledger_from_purchase; DROP INDEX @idx_badge_ledger_to_purchase; -DROP TABLE @badge_ledger; +ALTER TABLE @badge_ledger DROP COLUMN payment_id; +ALTER TABLE @badge_ledger DROP COLUMN charge_id; +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; DROP INDEX @idx_badge_subscription_changes_purchase; DROP TABLE @badge_subscription_changes; DROP INDEX @idx_badge_invoices_purchase; DROP INDEX @idx_badge_invoices_offer; DROP INDEX @idx_badge_invoices_price; DROP TABLE @badge_invoices; -DROP TABLE @badge_purchases; +DROP INDEX @idx_badge_purchases_payment; +ALTER TABLE @badge_purchases DROP COLUMN payment_id; DROP TABLE @subscription_charges; -DROP INDEX @idx_payments_provider_ref; -DROP INDEX @idx_payments_invoice; -DROP TABLE @payments; -DROP TABLE @invoices; -DROP INDEX @idx_badge_offers_price; -DROP TABLE @badge_offers; -DROP TABLE @badge_prices; -|] +-} -m20261001_user_badges :: Query -m20261001_user_badges = +m20260915_user_badges :: Query +m20260915_user_badges = badgeSchema "" <> [sql| ALTER TABLE badge_purchases ADD COLUMN user_id INTEGER REFERENCES users ON DELETE CASCADE; @@ -234,10 +251,6 @@ ALTER TABLE badge_purchases ADD COLUMN alert_acked_episode TEXT; ALTER TABLE badge_purchases ADD COLUMN alert_snooze_until TEXT; -ALTER TABLE payments ADD COLUMN evidence BLOB; - -ALTER TABLE payments ADD COLUMN receipt_code TEXT; - ALTER TABLE badge_ledger ADD COLUMN entry_type_unknown INTEGER NOT NULL DEFAULT 0; ALTER TABLE badge_ledger ADD COLUMN entry_type_value TEXT; @@ -268,8 +281,8 @@ ALTER TABLE badge_purchases ADD COLUMN badge_code_redemption_id INTEGER REFERENC CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(badge_code_redemption_id); |] -down_m20261001_user_badges :: Query -down_m20261001_user_badges = +down_m20260915_user_badges :: Query +down_m20260915_user_badges = [sql| DROP INDEX idx_badge_purchases_code_redemption; DROP INDEX idx_badge_purchases_user; @@ -281,3 +294,14 @@ ALTER TABLE users DROP COLUMN shown_badge_id; DROP INDEX idx_badge_code_redemptions_user; DROP TABLE badge_code_redemptions; |] + +{- TODO [badges] deferred with the draft above, client only. + +ALTER TABLE payments ADD COLUMN evidence BLOB; + +ALTER TABLE payments ADD COLUMN receipt_code TEXT; + +-- down +ALTER TABLE payments DROP COLUMN evidence; +ALTER TABLE payments DROP COLUMN receipt_code; +-} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 2706bf9fcf..090b6332ab 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -899,20 +899,6 @@ CREATE TABLE payments( cancelled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL - , - evidence BLOB, - receipt_code TEXT -) STRICT; -CREATE TABLE subscription_charges( - charge_id TEXT NOT NULL PRIMARY KEY, - payment_id TEXT NOT NULL REFERENCES payments ON DELETE CASCADE, - provider_charge_ref TEXT NOT NULL, - period_start TEXT NOT NULL, - period_end TEXT NOT NULL, - amount INTEGER NOT NULL, - currency TEXT NOT NULL, - charged_at TEXT NOT NULL, - UNIQUE(payment_id, provider_charge_ref) ) STRICT; CREATE TABLE badge_prices( price_id TEXT NOT NULL PRIMARY KEY, @@ -937,7 +923,6 @@ CREATE TABLE badge_purchases( master_key BLOB NOT NULL, initial_badge_type TEXT NOT NULL, current_badge_type TEXT NOT NULL, - payment_id TEXT REFERENCES payments, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -947,29 +932,7 @@ CREATE TABLE badge_purchases( alert_acked_episode TEXT, alert_snooze_until TEXT, badge_code_redemption_id INTEGER REFERENCES badge_code_redemptions, - UNIQUE(purchase_key), - UNIQUE(payment_id) -) STRICT; -CREATE TABLE badge_invoices( - invoice_id TEXT NOT NULL PRIMARY KEY REFERENCES invoices ON DELETE CASCADE, - badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, - price_id TEXT NOT NULL REFERENCES badge_prices, - offer_id TEXT REFERENCES badge_offers, - months INTEGER NOT NULL, - created_at TEXT NOT NULL -) STRICT; -CREATE TABLE badge_subscription_changes( - change_id TEXT NOT NULL PRIMARY KEY, - badge_purchase_id INTEGER NOT NULL REFERENCES badge_purchases ON DELETE CASCADE, - from_badge_type TEXT NOT NULL, - to_badge_type TEXT NOT NULL, - from_provider_ref TEXT, - to_provider_ref TEXT, - effective TEXT NOT NULL, - status TEXT NOT NULL, - effective_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + UNIQUE(purchase_key) ) STRICT; CREATE TABLE badge_ledger( entry_id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -985,11 +948,7 @@ CREATE TABLE badge_ledger( created_at TEXT NOT NULL, entry_type TEXT NOT NULL, entry_credit_type TEXT, - entry_debit_type TEXT, - payment_id TEXT REFERENCES payments, - charge_id TEXT REFERENCES subscription_charges, - from_purchase_id INTEGER REFERENCES badge_purchases, - to_purchase_id INTEGER REFERENCES badge_purchases + entry_debit_type TEXT , entry_type_unknown INTEGER NOT NULL DEFAULT 0, entry_type_value TEXT, @@ -1557,21 +1516,11 @@ CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs( CREATE INDEX idx_payments_provider_ref ON payments(provider, provider_ref); CREATE INDEX idx_payments_invoice ON payments(invoice_id); CREATE INDEX idx_badge_offers_price ON badge_offers(price_id); -CREATE INDEX idx_badge_invoices_purchase ON badge_invoices(badge_purchase_id); -CREATE INDEX idx_badge_invoices_offer ON badge_invoices(offer_id); -CREATE INDEX idx_badge_invoices_price ON badge_invoices(price_id); -CREATE INDEX idx_badge_subscription_changes_purchase ON badge_subscription_changes( - badge_purchase_id -); CREATE UNIQUE INDEX idx_badge_ledger_uuid ON badge_ledger(entry_uuid); CREATE INDEX idx_badge_ledger_purchase ON badge_ledger( badge_purchase_id, entry_id ); -CREATE INDEX idx_badge_ledger_payment ON badge_ledger(payment_id); -CREATE INDEX idx_badge_ledger_charge ON badge_ledger(charge_id); -CREATE INDEX idx_badge_ledger_from_purchase ON badge_ledger(from_purchase_id); -CREATE INDEX idx_badge_ledger_to_purchase ON badge_ledger(to_purchase_id); CREATE INDEX idx_badge_issuances_purchase ON badge_issuances( badge_purchase_id, issuance_id From a7aed3ebd5fdc89e89914dd06f9dea4259d8ed6e Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:26:53 +0400 Subject: [PATCH 15/16] core: update query plans --- .../SQLite/Migrations/chat_query_plans.txt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index f8fdba1c5f..6726d2ffaf 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1529,6 +1529,15 @@ Plan: SEARCH connections USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT badge_purchase_id FROM badge_purchases + WHERE user_id = ? AND purchase_priv_key IS NOT NULL + ORDER BY badge_purchase_id DESC + LIMIT 1 + +Plan: +SEARCH badge_purchases USING INDEX idx_badge_purchases_user (user_id=?) + Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, @@ -4034,13 +4043,11 @@ Query: p.alert_acked_kind, p.alert_acked_episode, p.alert_snooze_until FROM badge_purchases p JOIN users u ON u.user_id = p.user_id - WHERE p.user_id = ? AND p.purchase_priv_key IS NOT NULL - ORDER BY p.badge_purchase_id DESC - LIMIT 1 + WHERE p.badge_purchase_id = ? AND p.purchase_priv_key IS NOT NULL Plan: +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH u USING INTEGER PRIMARY KEY (rowid=?) -SEARCH p USING INDEX idx_badge_purchases_user (user_id=?) Query: SELECT pgm.message_id, m.shared_msg_id, m.msg_body, m.msg_chat_binding, m.msg_signatures @@ -4276,6 +4283,14 @@ SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id= LIST SUBQUERY 1 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +Query: + UPDATE contact_profiles + SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? + WHERE user_id = ? AND contact_profile_id = ? + +Plan: +SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -5292,14 +5307,6 @@ Query: Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE contact_profiles - SET badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, updated_at = ? - WHERE user_id = ? AND contact_profile_id = ? - -Plan: -SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contact_profiles SET contact_link = ?, updated_at = ? @@ -7325,6 +7332,10 @@ Query: SELECT badge_purchase_id FROM badge_purchases WHERE badge_code_redemption Plan: SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_code_redemption (badge_code_redemption_id=?) +Query: SELECT balance_checked FROM badge_ledger ORDER BY entry_id +Plan: +SCAN badge_ledger + Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ? Plan: SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name From 6a7eae2751c9c8a987e712c9fb4b357228adaea6 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:23:13 +0000 Subject: [PATCH 16/16] ui: redeem code (#7482) --- apps/ios/Shared/Model/AppAPITypes.swift | 29 +++ apps/ios/Shared/Model/ChatModel.swift | 24 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 152 ++++++++++- .../Views/Badges/BadgeUserPreview.swift | 3 +- .../Views/Badges/BadgesHowItWorksView.swift | 7 +- .../Views/Badges/BadgesRedeemCodeView.swift | 210 ++++++++++++++- .../Badges/BadgesSupportSimplexView.swift | 67 +++-- apps/ios/Shared/Views/Badges/BadgesView.swift | 35 +++ .../Views/Badges/BadgesYourBadgeView.swift | 93 +++++++ .../Views/Badges/SupportSimpleXBanner.swift | 16 +- .../Shared/Views/ChatList/ChatListView.swift | 54 +++- .../Shared/Views/ChatList/UserPicker.swift | 3 +- .../Shared/Views/NewChat/NewChatView.swift | 3 +- .../Views/Onboarding/WhatsNewView.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 5 +- .../Views/UserSettings/UserProfilesView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 24 +- apps/ios/SimpleXChat/API.swift | 8 + apps/ios/SimpleXChat/ChatTypes.swift | 88 ++++++- apps/ios/SimpleXChat/SimpleX.h | 2 + .../foss/java/chat/simplex/app/PlayStore.kt | 2 + .../google/java/chat/simplex/app/PlayStore.kt | 2 + .../main/java/chat/simplex/app/SimplexApp.kt | 2 + .../src/commonMain/cpp/android/simplex-api.c | 9 + .../src/commonMain/cpp/desktop/simplex-api.c | 9 + .../chat/simplex/common/model/ChatModel.kt | 110 ++++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 166 ++++++++++++ .../chat/simplex/common/platform/Core.kt | 1 + .../chat/simplex/common/platform/Platform.kt | 1 + .../common/views/badges/BadgeUserPreview.kt | 4 +- .../views/badges/BadgesRedeemCodeView.kt | 246 +++++++++++++++++- .../views/badges/BadgesSupportSimplexView.kt | 74 ++++-- .../simplex/common/views/badges/BadgesView.kt | 25 ++ .../views/badges/BadgesYourBadgeView.kt | 76 ++++++ .../views/badges/SupportSimpleXBanner.kt | 19 +- .../common/views/chatlist/ChatListView.kt | 55 +++- .../common/views/newchat/OnboardingCards.kt | 7 +- .../common/views/onboarding/WhatsNewView.kt | 4 +- .../common/views/usersettings/SettingsView.kt | 7 +- .../commonMain/resources/MR/ar/strings.xml | 30 --- .../commonMain/resources/MR/base/strings.xml | 66 ++--- .../commonMain/resources/MR/bg/strings.xml | 18 -- .../commonMain/resources/MR/ca/strings.xml | 18 -- .../commonMain/resources/MR/cs/strings.xml | 24 -- .../commonMain/resources/MR/da/strings.xml | 11 - .../commonMain/resources/MR/de/strings.xml | 30 --- .../commonMain/resources/MR/el/strings.xml | 19 -- .../commonMain/resources/MR/es/strings.xml | 30 --- .../commonMain/resources/MR/fa/strings.xml | 18 -- .../commonMain/resources/MR/fr/strings.xml | 30 --- .../commonMain/resources/MR/hr/strings.xml | 6 - .../commonMain/resources/MR/hu/strings.xml | 30 --- .../commonMain/resources/MR/in/strings.xml | 28 -- .../commonMain/resources/MR/it/strings.xml | 30 --- .../commonMain/resources/MR/iw/strings.xml | 3 - .../commonMain/resources/MR/ja/strings.xml | 28 -- .../commonMain/resources/MR/ko/strings.xml | 4 - .../commonMain/resources/MR/ku/strings.xml | 3 - .../commonMain/resources/MR/lv/strings.xml | 18 -- .../commonMain/resources/MR/nl/strings.xml | 16 -- .../commonMain/resources/MR/pl/strings.xml | 19 -- .../resources/MR/pt-rBR/strings.xml | 30 --- .../commonMain/resources/MR/ro/strings.xml | 19 -- .../commonMain/resources/MR/ru/strings.xml | 30 --- .../commonMain/resources/MR/sk/strings.xml | 8 - .../commonMain/resources/MR/tr/strings.xml | 30 --- .../commonMain/resources/MR/uk/strings.xml | 24 -- .../commonMain/resources/MR/vi/strings.xml | 21 -- .../resources/MR/zh-rCN/strings.xml | 30 --- .../resources/MR/zh-rTW/strings.xml | 29 --- flake.nix | 2 + libsimplex.dll.def | 1 + src/Simplex/Chat.hs | 3 +- src/Simplex/Chat/Badges/Types.hs | 2 + src/Simplex/Chat/Library/Commands.hs | 5 +- src/Simplex/Chat/Mobile.hs | 10 + 76 files changed, 1590 insertions(+), 781 deletions(-) create mode 100644 apps/ios/Shared/Views/Badges/BadgesView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index db70511a5a..4a629bbd41 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -191,6 +191,10 @@ enum ChatCommand: ChatCmdProtocol { case apiUploadStandaloneFile(userId: Int64, file: CryptoFile) case apiDownloadStandaloneFile(userId: Int64, url: String, file: CryptoFile) case apiStandaloneFileInfo(url: String) + // badges + case apiRedeemBadgeCode(userId: Int64, code: String) + case apiGetBadgeState(userId: Int64) + case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) // misc case showVersion case getAgentSubsTotal(userId: Int64) @@ -413,6 +417,10 @@ enum ChatCommand: ChatCmdProtocol { case let .apiUploadStandaloneFile(userId, file): return "/_upload \(userId) \(file.filePath)" case let .apiDownloadStandaloneFile(userId, link, file): return "/_download \(userId) \(link) \(file.filePath)" case let .apiStandaloneFileInfo(link): return "/_download info \(link)" + case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" + case let .apiGetBadgeState(userId): return "/_badge state \(userId)" + case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): + return "/_badge ack \(userId) \(badgePurchaseId) \(alertKind.text) \(onOff(snooze)) \(episode)" case .showVersion: return "/version" case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" @@ -600,6 +608,9 @@ enum ChatCommand: ChatCmdProtocol { case .apiUploadStandaloneFile: return "apiUploadStandaloneFile" case .apiDownloadStandaloneFile: return "apiDownloadStandaloneFile" case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" + case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" + case .apiGetBadgeState: return "apiGetBadgeState" + case .apiAckBadgeAlert: return "apiAckBadgeAlert" case .showVersion: return "showVersion" case .getAgentSubsTotal: return "getAgentSubsTotal" case .getAgentServersSummary: return "getAgentServersSummary" @@ -653,6 +664,9 @@ enum ChatCommand: ChatCmdProtocol { return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) case let .testStorageEncryption(key): return .testStorageEncryption(key: obfuscate(key)) + // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + case let .apiRedeemBadgeCode(userId, code): + return .apiRedeemBadgeCode(userId: userId, code: obfuscate(code)) default: return self } } @@ -1019,6 +1033,10 @@ enum ChatResponse2: Decodable, ChatAPIResult { case archiveExported(archiveErrors: [ArchiveError]) case archiveImported(archiveErrors: [ArchiveError]) case appSettings(appSettings: AppSettings) + // badges + // the full user, not UserRef: its profile carries the badge that setUserBadge just stored + case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool) + case badgeState(user: UserRef, badgeState: BadgeState?) var responseType: String { switch self { @@ -1070,6 +1088,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .archiveExported: "archiveExported" case .archiveImported: "archiveImported" case .appSettings: "appSettings" + case .badgeRedeemed: "badgeRedeemed" + case .badgeState: "badgeState" } } @@ -1123,6 +1143,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveExported(archiveErrors): return String(describing: archiveErrors) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) + case let .badgeRedeemed(u, redeemedBadge, newBadge): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)") + case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) } } } @@ -1205,6 +1227,9 @@ enum ChatEvent: Decodable, ChatAPIResult { case remoteCtrlStopped(rcsState: RemoteCtrlSessionState, rcStopReason: RemoteCtrlStopReason) // pq case contactPQEnabled(user: UserRef, contact: Contact, pqEnabled: Bool) + // badges + case badgeChanged(user: User, badgeState: BadgeState?) + case badgeAlert(user: UserRef, badgeAlert: BadgeAlert) var responseType: String { switch self { @@ -1277,6 +1302,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .remoteCtrlConnected: "remoteCtrlConnected" case .remoteCtrlStopped: "remoteCtrlStopped" case .contactPQEnabled: "contactPQEnabled" + case .badgeChanged: "badgeChanged" + case .badgeAlert: "badgeAlert" } } @@ -1359,6 +1386,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .remoteCtrlConnected(remoteCtrl): return String(describing: remoteCtrl) case let .remoteCtrlStopped(rcsState, rcStopReason): return "rcsState: \(String(describing: rcsState))\nrcStopReason: \(String(describing: rcStopReason))" case let .contactPQEnabled(u, contact, pqEnabled): return withUser(u, "contact: \(String(describing: contact))\npqEnabled: \(pqEnabled)") + case let .badgeChanged(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeAlert(u, badgeAlert): return withUser(u, String(describing: badgeAlert)) } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..1bf711ca5f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -359,6 +359,30 @@ class ChannelRelaysModel: ObservableObject { } } +// The badge of whichever profile it was last loaded for, kept current by the badgeChanged event so +// that a screen already open shows what the renewal worker did with no command behind it. +class BadgeModel: ObservableObject { + static let shared = BadgeModel() + @Published private(set) var userId: Int64? + @Published private(set) var badgeState: BadgeState? + @Published private(set) var alert: BadgeAlert? + + // alert follows the state: getUserBadgeState derives it on every read, so a badgeChanged is + // never staler than the alert it carries - the invariant a new alert kind must keep + func set(userId: Int64, badgeState: BadgeState?) { + self.userId = userId + self.badgeState = badgeState + alert = badgeState?.alert + } + + func setAlert(userId: Int64, alert: BadgeAlert) { + if self.userId == userId { + self.alert = alert + badgeState?.alert = alert + } + } +} + // Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d7f29087d8..b90527507c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2182,6 +2182,119 @@ func resetAgentServersStats() async throws { try await sendCommandOkResp(.resetAgentServersStats) } +// The failures redeemBadgeCode raises, as core flattens them to command error text. +// tests/Bots/BadgeServiceTests.hs asserts that text, so a change in core breaks a test there. +enum BadgeRedeemError: Error { + case invalidCode + case serviceNotConfigured + case alreadyActive + case codeInvalid + case codeUsed + case codeExpired + case rateLimited + case serviceFailed + case badServiceResponse + case credentialNotVerified + case unsupportedVersion + case networkError + case badgeEnded + case unknown +} + +private enum BadgeErrorText { + static let invalidCode = "invalid badge code" + static let serviceNotConfigured = "badge service not configured" + static let alreadyActive = "badge already active" + static let unknownKeyIndex = "unknown badge key index" + static let credentialNotVerified = "badge credential does not verify against configured key" + static let invalidResponse = "invalid badge service response, " + static let unexpectedResponse = "unexpected badge service response: " + static let serviceError = "badge service error: " + // raised by sendServiceRequestTo, not by redeemBadgeCode itself, when the reply is not JSON + static let undecodableResponse = "invalid service response" +} + +func badgeRedeemError(_ error: ChatError) -> BadgeRedeemError { + // the app's own classifier decides what counts as a network failure; only the classification is + // used here, not its retry policy - a client that retries makes code guessing cheaper + if retryableNetworkErrorAlert(error) != nil { return .networkError } + guard case let .error(.commandError(message)) = error else { return .unknown } + switch message { + case BadgeErrorText.invalidCode: return .invalidCode + case BadgeErrorText.serviceNotConfigured: return .serviceNotConfigured + case BadgeErrorText.alreadyActive: return .alreadyActive + case BadgeErrorText.unknownKeyIndex, BadgeErrorText.credentialNotVerified: return .credentialNotVerified + case BadgeErrorText.undecodableResponse: return .badServiceResponse + default: break + } + if message.hasPrefix(BadgeErrorText.invalidResponse) || message.hasPrefix(BadgeErrorText.unexpectedResponse) { + return .badServiceResponse + } + if message.hasPrefix(BadgeErrorText.serviceError) { + return badgeServiceError(String(message.dropFirst(BadgeErrorText.serviceError.count))) + } + return .unknown +} + +// the service's own tag, which core bounds to [a-z0-9_] and 32 characters and which is never shown +private func badgeServiceError(_ tag: String) -> BadgeRedeemError { + switch tag { + case "code_invalid": return .codeInvalid + case "code_used": return .codeUsed + case "code_expired": return .codeExpired + case "rate_limited": return .rateLimited + // retrying never succeeds: the client is too old for the service + case "unsupported_version": return .unsupportedVersion + default: return .serviceFailed + } +} + +// log: false because the code is a bearer secret until it is redeemed - it is in the command, and a +// service response echoed into an error message would carry it into the terminal with the response. +func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: User, newBadge: Bool) { + let r: APIResult = await chatApiSendCmd(.apiRedeemBadgeCode(userId: userId, code: code), log: false) + switch r { + // redeemedBadge is dropped: it is the credential, and the user's profile carries what is shown + case let .result(.badgeRedeemed(user, _, newBadge)): return (user, newBadge) + case let .error(e): throw badgeRedeemError(e) + default: + // the response type alone - it names a case or a JSON key, never the service's message + logger.error("apiRedeemBadgeCode: unexpected \(r.responseType)") + throw BadgeRedeemError.unknown + } +} + +func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { + let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeState(userId: userId)) + if case let .badgeState(_, badgeState) = r { return badgeState } + throw r.unexpected +} + +func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { + let r: ChatResponse2 = try chatSendCmdSync(.apiGetBadgeState(userId: userId)) + if case let .badgeState(_, badgeState) = r { return badgeState } + 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 @@ -2352,12 +2465,34 @@ func getUserChatData() throws { 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 = try apiGetBadgeStateSync(userId) + BadgeModel.shared.set(userId: userId, badgeState: badgeState) + } catch let error { + logger.error("loadBadgeState: \(responseError(error))") + } +} + +private func loadBadgeStateAsync(_ userId: Int64) async { + do { + let badgeState = try await apiGetBadgeState(userId) + await MainActor.run { BadgeModel.shared.set(userId: userId, badgeState: badgeState) } + } catch let error { + logger.error("loadBadgeState: \(responseError(error))") + } } private func getUserChatDataAsync(keepingChatId: String?) async throws { let m = ChatModel.shared let tm = ChatTagsModel.shared - if m.currentUser != nil { + if let userId = m.currentUser?.userId { let userAddress = try await apiGetUserAddressAsync() let chatItemTTL = try await getChatItemTTLAsync() let chats = try await apiGetChatsAsync() @@ -2370,6 +2505,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { tm.userTags = tags tm.updateChatTags(m.chats) } + await loadBadgeStateAsync(userId) } else { await MainActor.run { m.userAddress = nil @@ -2948,6 +3084,20 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateContact(contact) } } + case let .badgeChanged(user, badgeState): + if active(user) { + await MainActor.run { + // read by core after retiring or presenting, so it carries the profile badge as changed + m.updateUser(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) + } + } default: logger.debug("unsupported event: \(res.responseType)") } diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift index 3e7216866e..45d94c3ed6 100644 --- a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -23,7 +23,8 @@ struct BadgeUserPreview: View { let user = chatModel.currentUser let displayName = user?.displayName ?? NSLocalizedString("My nickname", comment: "badges preview placeholder") let previewBadge = LocalBadge( - badge: BadgeInfo(badgeType: level.badgeType), + // fabricated for the preview: the status is given here, and NameBadge renders from it alone + badge: BadgeInfo(badgeType: level.badgeType, badgeExpiry: .distantFuture), status: .active ) return VStack(spacing: 12) { diff --git a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift index 24836b37f9..86035cc223 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -9,7 +9,6 @@ import SwiftUI import SimpleXChat -// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. struct BadgesHowItWorksView: View { @EnvironmentObject var theme: AppTheme @@ -23,9 +22,9 @@ struct BadgesHowItWorksView: View { .padding(.bottom, 16) ScrollView { VStack(alignment: .leading, spacing: 12) { - Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") - Text("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.") - Text("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + Text("A badge is not an account. It is a signed credential stored on your device. It does not identify you, and no one keeps a record of who holds which badge.") + Text("Your contacts see the badge and its expiry date, and nothing else. The badge carries no identifier, so it cannot be used to find out who you are or to match you across chats.") + Text("Payment and badge are kept apart. Paying is one step; the badge is issued in another, under a key that exists only for that badge. Whoever handles the payment cannot see where the badge ends up.") } .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 2101bfa7f0..6298425e8c 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -8,22 +8,214 @@ import SwiftUI import SimpleXChat +import CodeScanner + +private let badgeCodePrefix = "SB" +private let badgeCodeBodyLength = 20 +private let badgeCodeGroupLength = 5 + +// Regroups what was typed; validity and the folding of ambiguous characters are core's alone. +private func formatBadgeCodeInput(_ s: String) -> String { + var normalized = "" + for c in s.prefix(256).uppercased() { + guard c.isLetter || c.isNumber else { continue } + normalized.append(c) + if normalized.count == badgeCodePrefix.count + badgeCodeBodyLength { break } + } + guard normalized.hasPrefix(badgeCodePrefix) else { return normalized } + var groups = [badgeCodePrefix] + var i = normalized.index(normalized.startIndex, offsetBy: badgeCodePrefix.count) + while i < normalized.endIndex { + let j = normalized.index(i, offsetBy: badgeCodeGroupLength, limitedBy: normalized.endIndex) ?? normalized.endIndex + groups.append(String(normalized[i.. some View { + Text("Redeem code") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("Paste the code from your receipt.") + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + codeField() + + pasteButton() + + ScannerInView( + showQRCodeScanner: $showQRCodeScanner, + scannerPaused: $submitting, + processQRCode: processQRCode, + scanMode: .oncePerCode, + placeholderBackground: Color(.tertiarySystemFill) + ) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + Spacer(minLength: 0) + + VStack(spacing: 10) { + submitButton() + .padding(.vertical, 10) + Color.clear + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + + private func codeField() -> some View { + TextField(text: $code) { Text(verbatim: "SB-XXXXX-XXXXX-XXXXX-XXXXX") } + .font(.body.monospaced()) + .multilineTextAlignment(.center) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.characters) + .disabled(submitting) + .padding(EdgeInsets(top: 14, leading: 12, bottom: 14, trailing: 12)) + .background(Color(.tertiarySystemFill)) + .cornerRadius(10.0) + .onChange(of: code) { applyCodeInput($0) } + } + + private func pasteButton() -> some View { + Button { + if let pasted = UIPasteboard.general.string { applyCodeInput(pasted) } + } label: { + Text("Paste") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + .disabled(submitting) + } + + private func applyCodeInput(_ s: String) { + let formatted = formatBadgeCodeInput(s) + if formatted != code { code = formatted } + canonicalCode = parseBadgeCode(formatted) + } + + private func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + let formatted = formatBadgeCodeInput(r.string) + if parseBadgeCode(formatted) == nil { + showAlert( + NSLocalizedString("Invalid QR code", comment: "alert title"), + message: NSLocalizedString("The code you scanned is not a badge code.", comment: "alert message") + ) + } else { + applyCodeInput(formatted) + redeem() + } + case let .failure(e): + logger.error("processQRCode QR code error: \(e.localizedDescription)") + showAlert( + NSLocalizedString("Invalid QR code", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Error scanning code: %@", comment: "alert message"), e.localizedDescription) + ) + } + } + + private func submitButton() -> some View { + let disabled = canonicalCode == nil || submitting + return Button { + redeem() + } label: { + Text("Redeem") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: disabled)) + .disabled(disabled) + } + + private func redeem() { + guard let sending = canonicalCode, let user = chatModel.currentUser else { return } + submitting = true + Task { + do { + let (redeemedUser, newBadge) = try await apiRedeemBadgeCode(user.userId, sending) + let badgeState = try? await apiGetBadgeState(user.userId) + await MainActor.run { + submitting = false + // written before the pop: BadgesView swaps its content under this pushed view, so + // the pop reveals Your Badge already in place rather than animating it afterwards + if let badgeState { + BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) + } + // the response is the only carrier: redeeming raises no event that refreshes the + // profile, so without this the badge beside the name is the one from before + chatModel.updateUser(redeemedUser) + if let badgeState, !badgeState.shown { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(newBadge ? .badgeEnded : .codeUsed)) + } else { + supporterBannerShown = true + dismiss() + } + } + } catch let error { + let redeemError = error as? BadgeRedeemError ?? .unknown + // the mapped case only - core embeds the service's response in some of these messages + logger.error("apiRedeemBadgeCode: \(String(describing: redeemError))") + await MainActor.run { + submitting = false + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(redeemError)) + } + } + } + } + + private func failureMessage(_ failure: BadgeRedeemError) -> String { + switch failure { + case .invalidCode: NSLocalizedString("This code is not valid.", comment: "alert message") + case .serviceNotConfigured: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .alreadyActive: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .codeInvalid: NSLocalizedString("This code was not recognised.", comment: "alert message") + case .codeUsed: NSLocalizedString("This code has already been used.", comment: "alert message") + case .codeExpired: NSLocalizedString("This code has expired.", comment: "alert message") + case .rateLimited: NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") + case .serviceFailed: NSLocalizedString("The badge service is unavailable. Please try again later.", comment: "alert message") + case .badServiceResponse: NSLocalizedString("The badge service sent an unexpected response.", comment: "alert message") + case .credentialNotVerified: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") + case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") + case .networkError: NSLocalizedString("Connection error. Please check your network connection.", comment: "alert message") + case .badgeEnded: NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + case .unknown: NSLocalizedString("The code could not be redeemed.", comment: "alert message") } - .padding(.horizontal, 25) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 2538d874a1..2fc904197d 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -15,11 +15,11 @@ struct BadgesSupportSimplexView: View { // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top var showsAsSheet: Bool = false @State private var whyBuiltActive = false + @State private var howItWorksActive = false @State private var chooseLevelActive = false @State private var redeemCodeActive = false var body: some View { - // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") GeometryReader { g in VStack(alignment: .center, spacing: 16) { Text("Support SimpleX") @@ -34,7 +34,9 @@ struct BadgesSupportSimplexView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - whyBuiltButton() + // TODO [badges] restore whyBuiltButton() when in-app purchase lands: the level screen + // returns to the flow and howItWorksButton() moves there, leaving this one alone here. + howItWorksButton() Spacer(minLength: 0) @@ -44,11 +46,10 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) - // Onboarding pattern: nested VStack(spacing: 10) + action button vertical padding 10. VStack(spacing: 10) { - chooseLevelButton() - .padding(.vertical, 10) redeemCodeButton() + .padding(.vertical, 10) + getCodeButton() .frame(height: 22) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) @@ -62,8 +63,27 @@ struct BadgesSupportSimplexView: View { } .frame(maxHeight: .infinity) .navigationBarTitleDisplayMode(.inline) - // preloaded here so the level screen shows store prices without a placeholder pass - .task { await BadgeStore.shared.load() } + } + + // the in-app purchase path, kept compiling and uncalled until payments return after the MVP + private func chooseLevelButton() -> some View { + ZStack { + Button { + chooseLevelActive = true + } label: { + Text("Choose your level") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $chooseLevelActive) { + BadgesYourLevelView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } } private func whyBuiltButton() -> some View { @@ -85,17 +105,17 @@ struct BadgesSupportSimplexView: View { } } - private func chooseLevelButton() -> some View { + private func howItWorksButton() -> some View { ZStack { - Button { - chooseLevelActive = true - } label: { - Text("Choose your level") + Button { howItWorksActive = true } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How private badges work").fontWeight(.medium) + } + .font(.body) } - .buttonStyle(OnboardingButtonStyle(isDisabled: false)) - - NavigationLink(isActive: $chooseLevelActive) { - BadgesYourLevelView() + NavigationLink(isActive: $howItWorksActive) { + BadgesHowItWorksView() .modifier(ThemedBackground()) } label: { EmptyView() @@ -111,10 +131,8 @@ struct BadgesSupportSimplexView: View { redeemCodeActive = true } label: { Text("Redeem badge code") - .font(.body) - .fontWeight(.medium) - .foregroundColor(theme.colors.primary) } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) NavigationLink(isActive: $redeemCodeActive) { BadgesRedeemCodeView() @@ -126,6 +144,17 @@ struct BadgesSupportSimplexView: View { .hidden() } } + + private func getCodeButton() -> some View { + Button { + openExternalLink(URL(string: "https://simplex.chat/badges/")!) + } label: { + Text("Get your code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + } } struct PhoneSupporterHero: View { diff --git a/apps/ios/Shared/Views/Badges/BadgesView.swift b/apps/ios/Shared/Views/Badges/BadgesView.swift new file mode 100644 index 0000000000..1960d25bf0 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesView.swift @@ -0,0 +1,35 @@ +// +// BadgesView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesView: View { + @EnvironmentObject var chatModel: ChatModel + @ObservedObject private var badgeModel = BadgeModel.shared + var showsAsSheet: Bool = false + + private var shownBadge: BadgeState? { + guard badgeModel.userId == chatModel.currentUser?.userId else { return nil } + if let badgeState = badgeModel.badgeState, badgeState.shown { return badgeState } + return nil + } + + var body: some View { + Group { + if let badgeState = shownBadge { + BadgesYourBadgeView(badgeState: badgeState, showsAsSheet: showsAsSheet) + .transition(.opacity) + } else { + BadgesSupportSimplexView(showsAsSheet: showsAsSheet) + .transition(.opacity) + } + } + .animation(.default, value: shownBadge != nil) + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift new file mode 100644 index 0000000000..ceb40e77cd --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -0,0 +1,93 @@ +// +// BadgesYourBadgeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesYourBadgeView: View { + @EnvironmentObject var theme: AppTheme + let badgeState: BadgeState + var showsAsSheet: Bool = false + + private var title: LocalizedStringKey { + badgeState.shown ? "Your badge" : "Support ended" + } + + // pushed, the navigation bar carries the title and animates it; as a sheet root there is no bar + // to put it in, so the title is drawn in the content, as the Support screen does + private var navTitle: LocalizedStringKey { showsAsSheet ? "" : title } + + var body: some View { + VStack(spacing: 0) { + if showsAsSheet { + Text(title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 25) + .padding(.top, 48) + } + + List { + Section { + BadgeSummary(badgeState: badgeState) + } + Section { + Text(badgeState.paidThroughText) + } header: { + Text("Ends") + .foregroundColor(theme.colors.secondary) + } footer: { + Text("Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out.") + .foregroundColor(theme.colors.secondary) + } + } + } + .frame(maxHeight: .infinity) + .navigationTitle(navTitle) + .navigationBarTitleDisplayMode(showsAsSheet ? .inline : .large) + .modifier(ThemedBackground(grouped: true)) + } +} + +struct BadgeSummary: View { + @EnvironmentObject var theme: AppTheme + let badgeState: BadgeState + + var body: some View { + VStack(spacing: 4) { + Image(badgeImageName(badgeState.badgeType)) + .resizable() + .scaledToFit() + .frame(width: 68, height: 68) + .padding(.bottom, 8) + + badgeTypeName(badgeState.badgeType) + .font(.title3) + .fontWeight(.semibold) + + Text("shown on your profile") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } +} + +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +private func badgeTypeName(_ t: BadgeType) -> Text { + switch t { + case .supporter: Text("Supporter") + case .legend: Text("Legend") + case .investor: Text("Investor") + case let .unknown(s): Text(verbatim: s) + } +} diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift index 07bf4366d2..dad3bb1b0a 100644 --- a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -12,7 +12,8 @@ import SimpleXChat struct SupportSimpleXBanner: View { @EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme: ColorScheme - @State private var showDismissAlert = false + var title: LocalizedStringKey = "Support SimpleX" + var subtitle: LocalizedStringKey = "Get badge + files up to 5GB" let onTap: () -> Void let onDismiss: () -> Void @@ -38,11 +39,11 @@ struct SupportSimpleXBanner: View { Button(action: onTap) { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 4) { - Text("Support SimpleX") + Text(title) .font(.headline) .foregroundColor(theme.colors.primary) .lineLimit(2) - Text("Get badge + files up to 5GB") + Text(subtitle) .font(.subheadline) .foregroundColor(theme.colors.onBackground) .lineLimit(2) @@ -71,14 +72,7 @@ struct SupportSimpleXBanner: View { .padding(.trailing, 16) .padding(.leading, 4) .contentShape(Rectangle()) - .onTapGesture { showDismissAlert = true } - } - .alert(isPresented: $showDismissAlert) { - Alert( - title: Text("Support SimpleX"), - message: Text("You can support SimpleX later in Settings."), - dismissButton: .default(Text("Ok"), action: onDismiss) - ) + .onTapGesture(perform: onDismiss) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index f8745a7eaf..da829a92ef 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -154,6 +154,7 @@ struct UserPickerSheetView: View { struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared + @ObservedObject private var badgeModel = BadgeModel.shared @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? @State private var showNewChatSheet = false @@ -215,7 +216,7 @@ struct ChatListView: View { } .appSheet(isPresented: $showBadgesSheet) { NavigationView { - BadgesSupportSimplexView(showsAsSheet: true) + BadgesView(showsAsSheet: true) .modifier(ThemedBackground()) } } @@ -371,8 +372,38 @@ struct ChatListView: View { } } + // the onboarding cards replace the whole chat list, and the support-ended banner lives in the + // list - a lapsed supporter is not a newcomer, and must be told even with no conversations yet private var shouldShowOnboarding: Bool { - !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && !supportEnded + } + + private var supportEnded: Bool { + badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId + } + + private func showSupportEndedDismissAlert() { + showAlert(NSLocalizedString("Support ended", comment: "alert title")) { + [ + UIAlertAction(title: NSLocalizedString("Remind me later", comment: "alert button"), style: .default) { _ in + Task { await ackBadgeAlert(snooze: true) } + }, + UIAlertAction(title: NSLocalizedString("Dismiss", comment: "alert button"), style: .default) { _ in + Task { await ackBadgeAlert(snooze: false) } + }, + cancelAlertAction + ] + } + } + + private func showSupportSimpleXDismissAlert() { + showAlert( + title: NSLocalizedString("Support SimpleX", comment: "alert title"), + message: NSLocalizedString("You can support SimpleX later in Settings.", comment: "alert message"), + buttonTitle: NSLocalizedString("Ok", comment: "alert button"), + buttonAction: { withAnimation { supporterBannerShown = true } }, + cancelButton: false + ) } private var hasConversations: Bool { @@ -426,10 +457,23 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - if !supporterBannerShown && chatModel.chats.count > 3 { + // one slot: a badge the user paid for ending outranks the pitch to get one + if supportEnded, let alert = badgeModel.alert { + SupportSimpleXBanner( + title: "Support ended", + subtitle: "Your support ended on \(alert.dateText).", + onTap: { showBadgesSheet = true }, + onDismiss: showSupportEndedDismissAlert + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } else if !supporterBannerShown && chatModel.chats.count > 3 { SupportSimpleXBanner( onTap: { showBadgesSheet = true }, - onDismiss: { withAnimation { supporterBannerShown = true } } + onDismiss: showSupportSimpleXDismissAlert ) .padding(.vertical, 3) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) @@ -537,7 +581,7 @@ struct ChatListView: View { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } - + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if !searchChatFilteredBySimplexLink.isEmpty { diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 8c230dc56a..a25ebf3419 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -42,7 +42,8 @@ struct UserPicker: View { HStack { ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground)) .padding(.trailing, 6) - profileName(user).lineLimit(1) + NameWithBadge(profileName(user), user.profile.localBadge, .title2) + .lineLimit(1) } .padding(rowPadding) .frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading) diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index f938fb0063..ef691b10f9 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -739,6 +739,7 @@ struct ScannerInView: View { let processQRCode: (_ resp: Result) -> Void @State private var cameraAuthorizationStatus: AVAuthorizationStatus? var scanMode: ScanMode = .continuous + var placeholderBackground = Color(uiColor: .secondarySystemGroupedBackground) var body: some View { Group { @@ -777,7 +778,7 @@ struct ScannerInView: View { .padding() .background( RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(uiColor: .secondarySystemGroupedBackground)) + .fill(placeholderBackground) ) .padding(.horizontal) .listRowBackground(Color.clear) diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index bb5f4cdc48..2fd21e39dd 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -698,7 +698,8 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), - // TODO [badges] finalise copy + Read more link before v7.1 ships. + // TODO [badges] restore when badges ship, with final copy and the Read more link. + /* VersionDescription( version: "v7.1", post: nil, @@ -710,6 +711,7 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + */ ] private let lastVersion = versionDescriptions.last!.version diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 7dbe517a1f..767acade71 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -346,11 +346,12 @@ struct SettingsView: View { Section { NavigationLink { - BadgesSupportSimplexView() + BadgesView() .modifier(ThemedBackground()) } label: { + let badgeType = chatModel.currentUser?.profile.localBadge?.badge.badgeType ?? .supporter ZStack(alignment: .leading) { - Image("badge-supporter") + Image(badgeImageName(badgeType)) .resizable() .scaledToFit() .frame(width: 24, height: 24) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 3f3adbcb2d..890c672bcd 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -346,7 +346,7 @@ struct UserProfilesView: View { HStack { ProfileImage(imageStr: user.image, size: 38) .padding(.trailing, 12) - Text(user.chatViewName) + NameWithBadge(Text(user.chatViewName), user.profile.localBadge) Spacer() if user.activeUser { Image(systemName: "checkmark").foregroundColor(theme.colors.onBackground) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9315416e16..6dba849f5d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -191,8 +191,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -200,6 +200,8 @@ 64E5E3632DF71A4E00A4D530 /* ContextContactRequestActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */; }; 64E5E3672DFC16A900A4D530 /* ContextProfilePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */; }; 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; + 64EB8A99305405C60089FFDF /* BadgesYourBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */; }; + 64EB8A9B3054347A0089FFDF /* BadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EB8A9A3054347A0089FFDF /* BadgesView.swift */; }; 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; 64FC8F9D2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */; }; @@ -580,8 +582,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -590,6 +592,8 @@ 64E5E3622DF71A4E00A4D530 /* ContextContactRequestActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextContactRequestActionsView.swift; sourceTree = ""; }; 64E5E3662DFC16A900A4D530 /* ContextProfilePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextProfilePickerView.swift; sourceTree = ""; }; 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; + 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourBadgeView.swift; sourceTree = ""; }; + 64EB8A9A3054347A0089FFDF /* BadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesView.swift; sourceTree = ""; }; 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; 64FC8F9C2E3B6DEF0068F384 /* ContextMemberContactActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMemberContactActionsView.swift; sourceTree = ""; }; @@ -752,8 +756,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -840,8 +844,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */, ); path = Libraries; sourceTree = ""; @@ -1208,6 +1212,7 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, 64C03BF1302F423300072BDE /* BadgesPayView.swift */, 64C03BF2302F423300072BDE /* BadgesRedeemCodeView.swift */, @@ -1216,6 +1221,7 @@ 64C03BF5302F423300072BDE /* BadgesYourLevelView.swift */, 64C03BF6302F423300072BDE /* BadgeUserPreview.swift */, 64C03BF7302F423300072BDE /* SupportSimpleXBanner.swift */, + 64EB8A98305405C60089FFDF /* BadgesYourBadgeView.swift */, ); path = Badges; sourceTree = ""; @@ -1685,6 +1691,7 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, + 64EB8A9B3054347A0089FFDF /* BadgesView.swift in Sources */, E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */, 6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, @@ -1731,6 +1738,7 @@ 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, + 64EB8A99305405C60089FFDF /* BadgesYourBadgeView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 6bf46fb0dd..09609ab7fd 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -163,6 +163,14 @@ public func chatJsonLength(_ s: String) -> Int { return Int(chat_json_length(&c)) } +// The code as core will accept it - prefix and 20 characters, no separators - or nil if it does not +// parse. Validity is decided only here: a second check-character implementation would drift. +public func parseBadgeCode(_ s: String) -> String? { + var c = s.cString(using: .utf8)! + let canonical = fromCString(chat_parse_badge_code(&c)!) + return canonical.isEmpty ? nil : canonical +} + struct ParsedMarkdown: Decodable { var formattedText: [FormattedText]? } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index da758e8100..b8f6fd0bb0 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -305,7 +305,7 @@ public struct BadgeInfo: Codable, Hashable { public var badgeExpiry: Date public var badgeExtra: String - public init(badgeType: BadgeType, badgeExpiry: Date? = nil, badgeExtra: String = "") { + public init(badgeType: BadgeType, badgeExpiry: Date, badgeExtra: String = "") { self.badgeType = badgeType self.badgeExpiry = badgeExpiry self.badgeExtra = badgeExtra @@ -322,6 +322,92 @@ public struct LocalBadge: Codable, Hashable { } } +// paidThrough is the only date to show the user: BadgeInfo.badgeExpiry is the credential's expiry, +// which outlives entitlement so the credential's window can cover a renewal. +public struct BadgeState: Codable, Hashable { + public var badgePurchaseId: Int64 + public var badgeType: BadgeType + public var shown: Bool + public var monthsLeft: Int + public var paidThrough: Date + public var renewsAt: Date? + public var willRenew: Bool + public var alert: BadgeAlert? + + public var paidThroughText: String { badgeDateText(paidThrough) } +} + +public struct BadgeAlert: Codable, Hashable { + public var kind: BadgeAlertKind + public var episode: String + public var date: Date + public var price: BadgeAlertPrice? + + public var dateText: String { badgeDateText(date) } +} + +private func badgeDateText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .none) +} + +public struct BadgeAlertPrice: Hashable { + public var amount: Int64 + public var currency: String +} + +extension BadgeAlertPrice: Codable { + // encoded as the Haskell tuple it comes from: [amount, currency] + public init(from decoder: Decoder) throws { + var c = try decoder.unkeyedContainer() + amount = try c.decode(Int64.self) + currency = try c.decode(String.self) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.unkeyedContainer() + try c.encode(amount) + try c.encode(currency) + } +} + +public enum BadgeAlertKind: Hashable { + case renewalApproaching + case paymentIssue + case subscriptionEnded + case prepaidEnding + case supportEnded + case unknown(String) + + public var text: String { + switch self { + case .renewalApproaching: "renewal_approaching" + case .paymentIssue: "payment_issue" + case .subscriptionEnded: "subscription_ended" + case .prepaidEnding: "prepaid_ending" + case .supportEnded: "support_ended" + case let .unknown(s): s + } + } +} + +extension BadgeAlertKind: Codable { + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "renewal_approaching": self = .renewalApproaching + case "payment_issue": self = .paymentIssue + case "subscription_ended": self = .subscriptionEnded + case "prepaid_ending": self = .prepaidEnding + case "support_ended": self = .supportEnded + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(text) + } +} + // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) public struct BadgeProof: Codable, Hashable { public var badgeKeyIdx: Int diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 5a3541e06d..543dc258e4 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -27,6 +27,8 @@ extern char *chat_parse_server(char *str); extern char *chat_parse_uri(char *str, int safe); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); +// returns the canonical form of a badge code, or an empty string if it does not parse +extern char *chat_parse_badge_code(char *code); extern int chat_json_length(char *str); extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt index bf4a3fb71d..1d8fc5e151 100644 --- a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -9,6 +9,8 @@ import chat.simplex.common.views.badges.BadgeStoreProductId // Play Billing is only in the google flavor, so the Play country stays unknown here fun loadPlayStoreCountry() {} +const val hasPlatformStore = false + // No store in this flavor: no product is offered, so the purchase screen shows nothing to buy // TODO [badges] this build pays via Stripe/crypto - the badge service catalog replaces these @Suppress("UNUSED_PARAMETER") diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt index 14e8fc470c..4075421e52 100644 --- a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +const val hasPlatformStore = true + // Requests the country of the Google Play account into [androidPlayStoreCountry]. // It stays null when Play is unavailable or the user is not signed in. fun loadPlayStoreCountry() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 9826908e77..1a4c836b2e 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -346,6 +346,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + override val androidHasPlatformStore: Boolean get() = hasPlatformStore + override suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List = loadBadgeProducts(oneTimeIds, subscriptionIds) override suspend fun androidPurchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurchaseOutcome = purchaseBadge(id, invoiceId) diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index fd7f71d49c..c0497daacb 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -67,6 +67,7 @@ extern char *chat_parse_server(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern char *chat_parse_badge_code(const char *code); extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); @@ -173,6 +174,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { + const char *_code = (*env)->GetStringUTFChars(env, code, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_parse_badge_code(_code)); + (*env)->ReleaseStringUTFChars(env, code, _code); + return res; +} + JNIEXPORT int JNICALL Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 9844a5927d..6bb6ffbe97 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -40,6 +40,7 @@ extern char *chat_parse_server(const char *str); extern char *chat_parse_uri(const char *str, const int safe); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern char *chat_parse_badge_code(const char *code); extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); @@ -183,6 +184,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseBadgeCode(JNIEnv *env, jclass clazz, jstring code) { + const char *_code = encode_to_utf8_chars(env, code); + jstring res = decode_to_utf8_string(env, chat_parse_badge_code(_code)); + (*env)->ReleaseStringUTFChars(env, code, _code); + return res; +} + JNIEXPORT int JNICALL Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { const char *_str = encode_to_utf8_chars(env, str); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 5e5a91f6a7..f033c0963e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -102,6 +102,34 @@ object ChannelRelaysModel { } } +// The badge of whichever profile it was last loaded for, kept current by the badgeChanged event so +// that a screen already open shows what the renewal worker did with no command behind it. +object BadgeModel { + val rhId = mutableStateOf(null) + val userId = mutableStateOf(null) + val badgeState = mutableStateOf(null) + val alert = mutableStateOf(null) + + // alert follows the state: getUserBadgeState derives it on every read, so a badgeChanged is + // never staler than the alert it carries - the invariant a new alert kind must keep + fun set(rhId: Long?, userId: Long, badgeState: BadgeState?) { + this.rhId.value = rhId + this.userId.value = userId + this.badgeState.value = badgeState + alert.value = badgeState?.alert + } + + fun setAlert(rhId: Long?, userId: Long, alert: BadgeAlert) { + if (isCurrent(rhId, userId)) { + this.alert.value = alert + badgeState.value = badgeState.value?.copy(alert = alert) + } + } + + fun isCurrent(rhId: Long?, userId: Long?): Boolean = + this.rhId.value == rhId && this.userId.value == userId +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ @@ -2192,6 +2220,88 @@ data class LocalBadge( val status: BadgeStatus ) +// paidThrough is the only date to show the user: BadgeInfo.badgeExpiry is the credential's expiry, +// which outlives entitlement so the credential's window can cover a renewal. +@Serializable +data class BadgeState( + val badgePurchaseId: Long, + val badgeType: BadgeType, + val shown: Boolean, + val monthsLeft: Int, + val paidThrough: Instant, + val renewsAt: Instant? = null, + val willRenew: Boolean, + val alert: BadgeAlert? = null +) { + val paidThroughText: String get() = badgeDateText(paidThrough) +} + +@Serializable +data class BadgeAlert( + val kind: BadgeAlertKind, + val episode: String, + val date: Instant, + val price: BadgeAlertPrice? = null +) { + val dateText: String get() = badgeDateText(date) +} + +@Serializable(with = BadgeAlertPriceSerializer::class) +data class BadgeAlertPrice(val amount: Long, val currency: String) + +// encoded as the Haskell tuple it comes from: [amount, currency] +object BadgeAlertPriceSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("BadgeAlertPrice") + override fun deserialize(decoder: Decoder): BadgeAlertPrice { + require(decoder is JsonDecoder) + val arr = decoder.decodeJsonElement().jsonArray + return BadgeAlertPrice(arr[0].jsonPrimitive.long, arr[1].jsonPrimitive.content) + } + override fun serialize(encoder: Encoder, value: BadgeAlertPrice) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonArray { add(value.amount); add(value.currency) }) + } +} + +@Serializable(with = BadgeAlertKindSerializer::class) +sealed class BadgeAlertKind { + @Serializable @SerialName("renewal_approaching") object RenewalApproaching: BadgeAlertKind() + @Serializable @SerialName("payment_issue") object PaymentIssue: BadgeAlertKind() + @Serializable @SerialName("subscription_ended") object SubscriptionEnded: BadgeAlertKind() + @Serializable @SerialName("prepaid_ending") object PrepaidEnding: BadgeAlertKind() + @Serializable @SerialName("support_ended") object SupportEnded: BadgeAlertKind() + @Serializable @SerialName("unknown") data class Unknown(val kind: String): BadgeAlertKind() + + val text: String + get() = when (this) { + is RenewalApproaching -> "renewal_approaching" + is PaymentIssue -> "payment_issue" + is SubscriptionEnded -> "subscription_ended" + is PrepaidEnding -> "prepaid_ending" + is SupportEnded -> "support_ended" + is Unknown -> kind + } +} + +object BadgeAlertKindSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeAlertKind", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeAlertKind = + when (val v = decoder.decodeString()) { + "renewal_approaching" -> BadgeAlertKind.RenewalApproaching + "payment_issue" -> BadgeAlertKind.PaymentIssue + "subscription_ended" -> BadgeAlertKind.SubscriptionEnded + "prepaid_ending" -> BadgeAlertKind.PrepaidEnding + "support_ended" -> BadgeAlertKind.SupportEnded + else -> BadgeAlertKind.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeAlertKind) = encoder.encodeString(value.text) +} + +private fun badgeDateText(date: Instant): String { + val ts = date.toLocalDateTime(TimeZone.currentSystemDefault()) + return ts.toJavaLocalDateTime().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)) +} + // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) @Serializable data class BadgeProof( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1865948530..ed787cf266 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -562,6 +562,94 @@ object ChatController { suspend fun resetAgentServersStats(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ResetAgentServersStats()) + fun badgeRedeemError(error: ChatError): BadgeRedeemError { + // the app's own classifier decides what counts as a network failure; only the classification is + // used here, not its retry policy - a client that retries makes code guessing cheaper + if (retryableNetworkErrorAlert(error) != null) return BadgeRedeemError.NetworkError + if (error !is ChatError.ChatErrorChat || error.errorType !is ChatErrorType.CommandError) return BadgeRedeemError.Unknown + val message = error.errorType.message + when (message) { + BadgeErrorText.invalidCode -> return BadgeRedeemError.InvalidCode + BadgeErrorText.serviceNotConfigured -> return BadgeRedeemError.ServiceNotConfigured + BadgeErrorText.alreadyActive -> return BadgeRedeemError.AlreadyActive + BadgeErrorText.unknownKeyIndex, BadgeErrorText.credentialNotVerified -> return BadgeRedeemError.CredentialNotVerified + BadgeErrorText.undecodableResponse -> return BadgeRedeemError.BadServiceResponse + } + if (message.startsWith(BadgeErrorText.invalidResponse) || message.startsWith(BadgeErrorText.unexpectedResponse)) { + return BadgeRedeemError.BadServiceResponse + } + if (message.startsWith(BadgeErrorText.serviceError)) { + return badgeServiceError(message.removePrefix(BadgeErrorText.serviceError)) + } + return BadgeRedeemError.Unknown + } + + // the service's own tag, which core bounds to [a-z0-9_] and 32 characters and which is never shown + private fun badgeServiceError(tag: String): BadgeRedeemError = when (tag) { + "code_invalid" -> BadgeRedeemError.CodeInvalid + "code_used" -> BadgeRedeemError.CodeUsed + "code_expired" -> BadgeRedeemError.CodeExpired + "rate_limited" -> BadgeRedeemError.RateLimited + // retrying never succeeds: the client is too old for the service + "unsupported_version" -> BadgeRedeemError.UnsupportedVersion + else -> BadgeRedeemError.ServiceFailed + } + + // log = false because the code is a bearer secret until it is redeemed - it is in the command, and a + // service response echoed into an error message would carry it into the terminal with the response. + suspend fun apiRedeemBadgeCode(rh: Long?, userId: Long, code: String): BadgeRedeemResult { + val r = sendCmd(rh, CC.ApiRedeemBadgeCode(userId, code), log = false) + return when { + // redeemedBadge is dropped: it is the credential, and the user's profile carries what is shown + r is API.Result && r.res is CR.BadgeRedeemed -> BadgeRedeemResult.Redeemed(r.res.user, r.res.newBadge) + r is API.Error -> BadgeRedeemResult.Failed(badgeRedeemError(r.err)) + else -> { + // the response type alone - it names a case or a JSON key, never the service's message + Log.e(TAG, "apiRedeemBadgeCode: unexpected ${r.responseType}") + BadgeRedeemResult.Failed(BadgeRedeemError.Unknown) + } + } + } + + suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { + val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + throw Exception("apiGetBadgeState: unexpected ${r.responseType}") + } + + suspend fun apiAckBadgeAlert(rh: Long?, userId: Long, badgePurchaseId: Long, alertKind: BadgeAlertKind, snooze: Boolean, episode: String): BadgeState? { + val r = sendCmd(rh, CC.ApiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode)) + if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState + throw Exception("apiAckBadgeAlert: unexpected ${r.responseType}") + } + + // 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. + suspend fun ackBadgeAlert(snooze: Boolean) { + val rhId = BadgeModel.rhId.value + val userId = BadgeModel.userId.value ?: return + val purchaseId = BadgeModel.badgeState.value?.badgePurchaseId ?: return + val alert = BadgeModel.alert.value ?: return + try { + val badgeState = apiAckBadgeAlert(rhId, userId, purchaseId, alert.kind, snooze = snooze, episode = alert.episode) + withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + } catch (e: Exception) { + Log.e(TAG, "ackBadgeAlert: ${e.message}") + } + } + + // 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 suspend fun loadBadgeState(rhId: Long?) { + try { + val userId = currentUserId("loadBadgeState") + val badgeState = apiGetBadgeState(rhId, userId) + withContext(Dispatchers.Main) { BadgeModel.set(rhId, userId, badgeState) } + } catch (e: Exception) { + Log.e(TAG, "loadBadgeState: ${e.message}") + } + } + private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock { val userId = chatModel.currentUser.value?.userId if (userId == null) { @@ -683,6 +771,7 @@ object ChatController { chatModel.userTags.value = if (hasUser) apiGetChatTags(rhId) ?: emptyList() else emptyList() chatModel.activeChatTagFilter.value = null chatModel.updateChatTags(rhId) + if (hasUser) loadBadgeState(rhId) } // Spec: spec/api.md#startReceiver @@ -3475,6 +3564,20 @@ object ChatController { chatModel.chatsContext.updateContact(rhId, r.contact) } } + is CR.BadgeChanged -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + // read by core after retiring or presenting, so it carries the profile badge as changed + chatModel.updateUser(r.user) + BadgeModel.set(rhId, r.user.userId, r.badgeState) + } + } + is CR.BadgeAlertR -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + BadgeModel.setAlert(rhId, r.user.userId, r.badgeAlert) + } + } else -> Log.d(TAG , "unsupported event: ${msg.responseType}") } @@ -3772,6 +3875,43 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } } +// The failures redeemBadgeCode raises, as core flattens them to command error text. +// tests/Bots/BadgeServiceTests.hs asserts that text, so a change in core breaks a test there. +enum class BadgeRedeemError { + InvalidCode, + ServiceNotConfigured, + AlreadyActive, + CodeInvalid, + CodeUsed, + CodeExpired, + RateLimited, + ServiceFailed, + BadServiceResponse, + CredentialNotVerified, + UnsupportedVersion, + NetworkError, + BadgeEnded, + Unknown +} + +private object BadgeErrorText { + const val invalidCode = "invalid badge code" + const val serviceNotConfigured = "badge service not configured" + const val alreadyActive = "badge already active" + const val unknownKeyIndex = "unknown badge key index" + const val credentialNotVerified = "badge credential does not verify against configured key" + const val invalidResponse = "invalid badge service response, " + const val unexpectedResponse = "unexpected badge service response: " + const val serviceError = "badge service error: " + // raised by sendServiceRequestTo, not by redeemBadgeCode itself, when the reply is not JSON + const val undecodableResponse = "invalid service response" +} + +sealed class BadgeRedeemResult { + class Redeemed(val user: User, val newBadge: Boolean): BadgeRedeemResult() + class Failed(val error: BadgeRedeemError): BadgeRedeemResult() +} + // ChatCommand // Spec: spec/api.md#CC sealed class CC { @@ -3950,6 +4090,10 @@ sealed class CC { class ApiUploadStandaloneFile(val userId: Long, val file: CryptoFile): CC() class ApiDownloadStandaloneFile(val userId: Long, val url: String, val file: CryptoFile): CC() class ApiStandaloneFileInfo(val url: String): CC() + // badges + class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() + class ApiGetBadgeState(val userId: Long): CC() + class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() // misc class ShowVersion(): CC() class ResetAgentServersStats(): CC() @@ -4171,6 +4315,9 @@ sealed class CC { is ApiUploadStandaloneFile -> "/_upload $userId ${file.filePath}" is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" + is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" + is ApiGetBadgeState -> "/_badge state $userId" + is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${alertKind.text} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" is GetAgentSubsTotal -> "/get subs total $userId" @@ -4351,6 +4498,9 @@ sealed class CC { is ApiUploadStandaloneFile -> "apiUploadStandaloneFile" is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile" is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" + is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" + is ApiGetBadgeState -> "apiGetBadgeState" + is ApiAckBadgeAlert -> "apiAckBadgeAlert" is ShowVersion -> "showVersion" is ResetAgentServersStats -> "resetAgentServersStats" is GetAgentSubsTotal -> "getAgentSubsTotal" @@ -4372,6 +4522,8 @@ sealed class CC { is ApiUnhideUser -> ApiUnhideUser(userId, obfuscate(viewPwd)) is ApiDeleteUser -> ApiDeleteUser(userId, delSMPQueues, obfuscateOrNull(viewPwd)) is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) + // a code is a bearer secret until it is redeemed, and the terminal shows and copies cmdString + is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) else -> this } @@ -6725,6 +6877,12 @@ sealed class CR { @Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR() @Serializable @SerialName("agentSubsTotal") class AgentSubsTotal(val user: UserRef, val subsTotal: SMPServerSubs, val hasSession: Boolean): CR() @Serializable @SerialName("agentServersSummary") class AgentServersSummary(val user: UserRef, val serversSummary: PresentedServersSummary): CR() + // badges + // the full user, not UserRef: its profile carries the badge that setUserBadge just stored + @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean): CR() + @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() // general @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() @@ -6911,6 +7069,10 @@ sealed class CR { is ArchiveExported -> "archiveExported" is ArchiveImported -> "archiveImported" is AppSettingsR -> "appSettings" + is BadgeRedeemed -> "badgeRedeemed" + is BadgeStateR -> "badgeState" + is BadgeChanged -> "badgeChanged" + is BadgeAlertR -> "badgeAlert" is Response -> "* $type" is Invalid -> "* invalid json" } @@ -7114,6 +7276,10 @@ sealed class CR { is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) + is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge") + is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) + is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) is Response -> json is Invalid -> str } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 3805a8e8b7..6b4e3e1353 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -33,6 +33,7 @@ external fun chatParseServer(str: String): String external fun chatParseUri(str: String, safe: Int): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String +external fun chatParseBadgeCode(code: String): String external fun chatJsonLength(str: String): Int external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index d857ab974c..7d126096e8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -38,6 +38,7 @@ interface PlatformInterface { // Play Billing, only implemented in the google flavor // TODO [badges] desktop and foss pay via Stripe/crypto - these defaults leave them without any // product until that path is implemented + val androidHasPlatformStore: Boolean get() = false suspend fun androidLoadBadgeProducts(oneTimeIds: List, subscriptionIds: List): List { Log.w(TAG, "androidLoadBadgeProducts: no store on this platform") return emptyList() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt index 6380e5cf79..03fabf817e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -16,13 +16,15 @@ import chat.simplex.common.platform.chatModel import chat.simplex.common.views.helpers.NameWithBadge import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.res.MR +import kotlinx.datetime.Instant @Composable fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { val user = chatModel.currentUser.value val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) val previewBadge = LocalBadge( - badge = BadgeInfo(badgeType = level.badgeType), + // fabricated for the preview: the status is given here, and NameBadge renders from it alone + badge = BadgeInfo(badgeType = level.badgeType, badgeExpiry = Instant.DISTANT_FUTURE), status = BadgeStatus.Active ) Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 8a1069c536..6462215051 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -1,29 +1,257 @@ package chat.simplex.common.views.badges +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource -import chat.simplex.common.platform.ColumnWithScrollBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.QRCodeScanner +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton import chat.simplex.res.MR -// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined. +private const val badgeCodePrefix = "SB" +private const val badgeCodeBodyLength = 20 +private const val badgeCodeGroupLength = 5 + +// The code as core will accept it - prefix and 20 characters, no separators - or null if it does not +// parse. Validity is decided only here: a second check-character implementation would drift. +fun parseBadgeCode(s: String): String? { + val canonical = chatParseBadgeCode(s) + return if (canonical.isEmpty()) null else canonical +} + +// Regroups what was typed; validity and the folding of ambiguous characters are core's alone. +private fun formatBadgeCodeInput(s: String): String { + val normalized = StringBuilder() + for (c in s.take(256).uppercase()) { + if (!c.isLetterOrDigit()) continue + normalized.append(c) + if (normalized.length == badgeCodePrefix.length + badgeCodeBodyLength) break + } + if (!normalized.startsWith(badgeCodePrefix)) return normalized.toString() + val groups = mutableListOf(badgeCodePrefix) + var i = badgeCodePrefix.length + while (i < normalized.length) { + val j = minOf(i + badgeCodeGroupLength, normalized.length) + groups.add(normalized.substring(i, j)) + i = j + } + return groups.joinToString("-") +} + @Composable fun BadgesRedeemCodeView() { + val rhId = remember { chatModel.remoteHostId() } + val supporterBannerShown = remember { appPrefs.supporterBannerShown } + val code = remember { mutableStateOf(TextFieldValue("")) } + val canonicalCode = remember { mutableStateOf(null) } + val submitting = remember { mutableStateOf(false) } + + fun applyCodeInput(s: String) { + val formatted = formatBadgeCodeInput(s) + if (formatted != code.value.text) code.value = TextFieldValue(formatted, selection = TextRange(formatted.length)) + canonicalCode.value = parseBadgeCode(formatted) + } + + fun redeem() { + val sending = canonicalCode.value ?: return + val user = chatModel.currentUser.value ?: return + submitting.value = true + withBGApi { + when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { + is BadgeRedeemResult.Redeemed -> { + val badgeState = try { chatModel.controller.apiGetBadgeState(rhId, user.userId) } catch (e: Exception) { null } + withContext(Dispatchers.Main) { + submitting.value = false + // written before the pop: BadgesView swaps its content under this pushed view, so + // the pop reveals Your Badge already in place rather than animating it afterwards + if (badgeState != null) { + BadgeModel.set(rhId, user.userId, badgeState) + } + // the response is the only carrier: redeeming raises no event that refreshes the + // profile, so without this the badge beside the name is the one from before + chatModel.updateUser(result.user) + if (badgeState != null && !badgeState.shown) { + // a replay adds no purchase; a fresh code's badge can be retired on arrival + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_error_title), + text = generalGetString(failureMessage(if (result.newBadge) BadgeRedeemError.BadgeEnded else BadgeRedeemError.CodeUsed)) + ) + } else { + supporterBannerShown.set(true) + ModalManager.start.closeModal() + } + } + } + is BadgeRedeemResult.Failed -> { + // the mapped case only - core embeds the service's response in some of these messages + Log.e(TAG, "apiRedeemBadgeCode: ${result.error}") + withContext(Dispatchers.Main) { + submitting.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_error_title), + text = generalGetString(failureMessage(result.error)) + ) + } + } + } + } + } + ColumnWithScrollBar( - Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), - horizontalAlignment = Alignment.Start + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, ) { Text( - stringResource(MR.strings.badges_redeem_code_button), + stringResource(MR.strings.badges_redeem_code_title), style = MaterialTheme.typography.h1, fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) + + Text( + stringResource(MR.strings.badges_redeem_code_body), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + CodeField(code, submitting.value, ::applyCodeInput) + + PasteButton(submitting.value, ::applyCodeInput) + + if (appPlatform.isAndroid) { + QRCodeScanner(padding = PaddingValues(start = 16.dp, top = 12.dp, end = 16.dp)) { text -> + val formatted = formatBadgeCodeInput(text) + when { + submitting.value -> false + parseBadgeCode(formatted) == null -> { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_qr_code), + text = generalGetString(MR.strings.badges_code_you_scanned_is_not_badge_code) + ) + false + } + else -> { + applyCodeInput(formatted) + redeem() + true + } + } + } + } + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SubmitButton(enabled = canonicalCode.value != null && !submitting.value, onClick = ::redeem) + TextButtonBelowOnboardingButton("", null) + } } } + +@Composable +private fun CodeField(code: MutableState, submitting: Boolean, applyCodeInput: (String) -> Unit) { + val colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.appColors.sentMessage, + textColor = MaterialTheme.colors.onBackground, + focusedIndicatorColor = Color.Unspecified, + unfocusedIndicatorColor = Color.Unspecified, + ) + BasicTextField( + value = code.value, + onValueChange = { applyCodeInput(it.text) }, + enabled = !submitting, + singleLine = true, + textStyle = TextStyle.Default.copy( + color = MaterialTheme.colors.onBackground, + fontFamily = FontFamily.Monospace, + fontSize = MaterialTheme.typography.body1.fontSize, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrect = false), + cursorBrush = SolidColor(colors.cursorColor(false).value), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(colors.backgroundColor(!submitting).value) + .padding(start = 12.dp, top = 14.dp, end = 12.dp, bottom = 14.dp), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.Center) { + if (code.value.text.isEmpty()) { + Text(stringResource(MR.strings.badges_redeem_code_placeholder), color = MaterialTheme.colors.secondary, fontFamily = FontFamily.Monospace) + } + innerTextField() + } + } + ) +} + +@Composable +private fun PasteButton(submitting: Boolean, applyCodeInput: (String) -> Unit) { + val clipboard = LocalClipboardManager.current + TextButton( + onClick = { clipboard.getText()?.text?.let { applyCodeInput(it) } }, + enabled = !submitting + ) { + Text(stringResource(MR.strings.paste_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_redeem, + onboarding = null, + enabled = enabled, + onclick = onClick + ) +} + +private fun failureMessage(failure: BadgeRedeemError): StringResource = when (failure) { + BadgeRedeemError.InvalidCode -> MR.strings.badges_error_invalid_code + BadgeRedeemError.ServiceNotConfigured -> MR.strings.badges_error_service_not_configured + BadgeRedeemError.AlreadyActive -> MR.strings.badges_error_already_active + BadgeRedeemError.CodeInvalid -> MR.strings.badges_error_code_invalid + BadgeRedeemError.CodeUsed -> MR.strings.badges_error_code_used + BadgeRedeemError.CodeExpired -> MR.strings.badges_error_code_expired + BadgeRedeemError.RateLimited -> MR.strings.badges_error_rate_limited + BadgeRedeemError.ServiceFailed -> MR.strings.badges_error_service_failed + BadgeRedeemError.BadServiceResponse -> MR.strings.badges_error_bad_service_response + BadgeRedeemError.CredentialNotVerified -> MR.strings.badges_error_credential_not_verified + BadgeRedeemError.UnsupportedVersion -> MR.strings.badges_error_unsupported_version + BadgeRedeemError.NetworkError -> MR.strings.badges_error_network + BadgeRedeemError.BadgeEnded -> MR.strings.badges_error_badge_ended + BadgeRedeemError.Unknown -> MR.strings.badges_error_unknown +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt index 099954a4f1..a9d935cdde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize @@ -32,10 +33,6 @@ import chat.simplex.res.MR @Composable fun BadgesSupportSimplexView() { - // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") - // preloaded here so the level screen shows store prices without a placeholder pass - LaunchedEffect(Unit) { BadgeStore.load() } - ColumnWithScrollBar( Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -58,15 +55,9 @@ fun BadgesSupportSimplexView() { modifier = Modifier.fillMaxWidth() ) - val primary = MaterialTheme.colors.primary - TextButton({ - ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } - }) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) - Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) - } - } + // TODO [badges] restore WhyBuiltButton() when in-app purchase lands: the level screen + // returns to the flow and HowItWorksButton() moves there, leaving this one alone here. + HowItWorksButton() Spacer(Modifier.weight(1f)) @@ -75,15 +66,13 @@ fun BadgesSupportSimplexView() { Spacer(Modifier.weight(1f)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - ChooseLevelButton() - TextButtonBelowOnboardingButton( - text = stringResource(MR.strings.badges_redeem_code_button), - onClick = { ModalManager.start.showModal { BadgesRedeemCodeView() } } - ) + RedeemCodeButton() + GetCodeButton() } } } +// the in-app purchase path, kept compiling and uncalled until payments return after the MVP @Composable private fun ChooseLevelButton() { OnboardingActionButton( @@ -96,6 +85,55 @@ private fun ChooseLevelButton() { ) } +@Composable +private fun WhyBuiltButton() { + val primary = MaterialTheme.colors.primary + TextButton({ + ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } +} + +@Composable +private fun HowItWorksButton() { + TextButton({ + ModalManager.start.showModal { BadgesHowItWorksView() } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_how_it_works_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } +} + +@Composable +private fun RedeemCodeButton() { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_redeem_code_button, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesRedeemCodeView() } + } + ) +} + +@Composable +private fun GetCodeButton() { + val uriHandler = LocalUriHandler.current + if (platform.androidHasPlatformStore) { + TextButtonBelowOnboardingButton("", null) + } else { + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_get_your_code), + onClick = { uriHandler.openExternalLink("https://simplex.chat/badges/") } + ) + } +} @Composable fun PhoneSupporterHero(modifier: Modifier = Modifier) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt new file mode 100644 index 0000000000..ac64c20d7c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -0,0 +1,25 @@ +package chat.simplex.common.views.badges + +import androidx.compose.animation.* +import androidx.compose.runtime.Composable +import chat.simplex.common.model.BadgeModel +import chat.simplex.common.model.BadgeState +import chat.simplex.common.platform.chatModel + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun BadgesView() { + val shownBadge: BadgeState? = run { + if (!BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return@run null + val badgeState = BadgeModel.badgeState.value + if (badgeState != null && badgeState.shown) badgeState else null + } + + AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }) { badgeState -> + if (badgeState != null) { + BadgesYourBadgeView(badgeState) + } else { + BadgesSupportSimplexView() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt new file mode 100644 index 0000000000..4ff8f2c6de --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -0,0 +1,76 @@ +package chat.simplex.common.views.badges + +import SectionSpacer +import SectionTextFooter +import SectionView +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.BadgeType +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.badgeImage +import chat.simplex.res.MR + +@Composable +fun BadgesYourBadgeView(badgeState: BadgeState) { + val title = if (badgeState.shown) MR.strings.badges_your_badge else MR.strings.badges_support_ended + + ColumnWithScrollBar { + AppBarTitle(stringResource(title)) + + SectionView { + BadgeSummary(badgeState) + } + SectionSpacer() + SectionView(stringResource(MR.strings.badges_ends).uppercase()) { + Text(badgeState.paidThroughText, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 12.dp)) + } + SectionTextFooter(stringResource(MR.strings.badges_prepaid_footer)) + SectionSpacer() + } +} + +@Composable +fun BadgeSummary(badgeState: BadgeState) { + Column( + Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Image( + painterResource(badgeImage(badgeState.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(68.dp).padding(bottom = 8.dp) + ) + + Text(badgeTypeName(badgeState.badgeType), style = MaterialTheme.typography.h3, fontWeight = FontWeight.SemiBold) + + Text( + stringResource(MR.strings.badges_shown_on_your_profile), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } +} + +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +@Composable +private fun badgeTypeName(t: BadgeType): String = when (t) { + is BadgeType.Supporter -> stringResource(MR.strings.badges_level_supporter) + is BadgeType.Legend -> stringResource(MR.strings.badges_level_legend) + is BadgeType.Investor -> stringResource(MR.strings.badges_type_investor) + is BadgeType.Unknown -> t.type +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt index 55a8cef525..94203e2169 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -31,7 +31,12 @@ import chat.simplex.common.views.newchat.lightStops import chat.simplex.res.MR @Composable -fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { +fun SupportSimpleXBanner( + title: String = generalGetString(MR.strings.badges_banner_title), + subtitle: String = generalGetString(MR.strings.badges_banner_subtitle), + onTap: () -> Unit, + onDismiss: () -> Unit +) { val cardCornerRadius = 16.dp // grows linearly with system font but never shrinks below the default so small-font users see the // same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text @@ -73,7 +78,7 @@ fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - stringResource(MR.strings.badges_banner_title), + title, style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colors.primary, @@ -81,7 +86,7 @@ fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { overflow = TextOverflow.Ellipsis ) Text( - stringResource(MR.strings.badges_banner_subtitle), + subtitle, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground, maxLines = 2, @@ -99,13 +104,7 @@ fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { .align(Alignment.TopEnd) .padding(end = 4.dp, top = 4.dp) .clip(CircleShape) - .clickable { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_banner_title), - text = generalGetString(MR.strings.badges_banner_dismiss_message), - onConfirm = onDismiss - ) - } + .clickable(onClick = onDismiss) .padding(8.dp) .size(16.dp) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 16a944d7bd..82e8a71bf2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -36,6 +36,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* +import SectionItemView import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call @@ -63,6 +64,41 @@ sealed class ActiveFilter { data object Unread: ActiveFilter() } +private fun showSupportEndedDismissAlert() { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.badges_support_ended), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { chatModel.controller.ackBadgeAlert(snooze = true) } + }) { + Text(stringResource(MR.strings.badges_remind_me_later), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withBGApi { chatModel.controller.ackBadgeAlert(snooze = false) } + }) { + Text(stringResource(MR.strings.badges_dont_show_again), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +private fun showSupportSimpleXDismissAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_banner_title), + text = generalGetString(MR.strings.badges_banner_dismiss_message), + onConfirm = { appPrefs.supporterBannerShown.set(true) } + ) +} + private fun showNewChatSheet(oneHandUI: State) { connectProgressManager.cancelConnectProgress() ModalManager.start.closeModals() @@ -1002,12 +1038,25 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ToggleChatListCard() } } - if (!supporterBannerShown.value && chatModel.chats.value.size > 3) { + // one slot: a badge the user paid for ending outranks the pitch to get one + val alert = BadgeModel.alert.value + if (supportEnded() && alert != null) { item { Box(Modifier.zIndex(1f).padding(16.dp)) { SupportSimpleXBanner( - onTap = { ModalManager.start.showModal { BadgesSupportSimplexView() } }, - onDismiss = { appPrefs.supporterBannerShown.set(true) } + title = stringResource(MR.strings.badges_support_ended), + subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), + onTap = { ModalManager.start.showModal { BadgesView() } }, + onDismiss = ::showSupportEndedDismissAlert + ) + } + } + } else if (!supporterBannerShown.value && chatModel.chats.value.size > 3) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + onTap = { ModalManager.start.showModal { BadgesView() } }, + onDismiss = ::showSupportSimpleXDismissAlert ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt index 26007c74af..9f4b213cb2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -42,13 +42,18 @@ import kotlin.math.sin private const val CARD_HEIGHT_RATIO = 0.75f private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 +// the onboarding cards replace the whole chat list, and the support-ended banner lives in the +// list - a lapsed supporter is not a newcomer, and must be told even with no conversations yet @Composable fun shouldShowOnboarding(): Boolean { val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } val chats = chatModel.chats.value - return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) && !supportEnded() } +fun supportEnded(): Boolean = + BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) + fun hasConversations(chats: List): Boolean = chats.any { chat -> when (val c = chat.chatInfo) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index 2588dcbdc7..ca9ee078d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -969,7 +969,8 @@ private val versionDescriptions: List = listOf( ), ) ), - // TODO [badges] finalise copy + Read more link before v7.1 ships. + // TODO [badges] restore when badges ship, with final copy and the Read more link. + /* VersionDescription( version = "v7.1", post = null, @@ -981,6 +982,7 @@ private val versionDescriptions: List = listOf( ) ) ), + */ ) @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 516e2c56bc..823a6b2f0c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.badges.BadgesSupportSimplexView +import chat.simplex.common.views.badges.BadgesView import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView @@ -107,8 +107,9 @@ fun SettingsLayout( SectionView { // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render // a gray top bar / back button that badges views don't want (they have their own inline titles). - SectionItemView(click = { ModalManager.start.showModal { BadgesSupportSimplexView() } }) { - Image(painterResource(MR.images.badge_supporter), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) + SectionItemView(click = { ModalManager.start.showModal { BadgesView() } }) { + val badgeType = chatModel.currentUser.value?.profile?.localBadge?.badge?.badgeType ?: BadgeType.Supporter + Image(painterResource(badgeImage(badgeType)), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) TextIconSpaced() Text(stringResource(MR.strings.supporter_perks)) } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 2ab1933418..5aa9270e4b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1754,12 +1754,10 @@ غير محمي السماح بالرجوع إلى إصدار سابق لا تستخدم التوجيه الخاص. - وضع توجيه الرسائل لا عندما يكون IP مخفيًا لا ترسل رسائل مباشرةً، حتى لو كان خادمك أو خادم الوجهة لا يدعم التوجيه الخاص. أرسل الرسائل مباشرة عندما لا يدعم الخادم الوجهة الخاص بك أو الخادم الوجهة التوجيه الخاص. - احتياطي توجيه الرسالة أظهِر حالة الرسالة احمِ عنوان IP بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لخوادم الملفات. @@ -1775,7 +1773,6 @@ خوادم غير معروفة! بدون تور أو VPN، سيكون عنوان IP الخاص بك مرئيًا لمُرحلات XFTP هذه: \n%1$s. - أظهِر قائمة الدردشة في نافذة جديدة ألوان الدردشة سمة الدردشة تلقيت رد @@ -1962,9 +1959,7 @@ متوسط ناعم مكالمة - اتصل مراسلة - افتح بحث الإعدادات فيديو @@ -2107,9 +2102,6 @@ عنوان SimpleX أو رابط لمرة واحدة؟ مُشغلي الشبكة يمكنك تضبيط الخوادم عبر الإعدادات. - حدد مشغلي الشبكة الذين تريد استخدامهم. - يمكنك تضبيط المُشغلين في إعدادات الشبكة والخوادم. - حدّث تابع الشروط المتفق عليها راجع الشروط @@ -2121,7 +2113,6 @@ مُشغل الشبكة المُشغل %s خوادم - الموقع الإلكتروني سيتم قبول الشروط في: %s. قُبل الشروط في: %s. استخدم %s @@ -2151,14 +2142,12 @@ فعّل flux في إعدادات الشبكة والخوادم لتحسين خصوصية البيانات الوصفية. اللامركزية الشبكية المُشغل المُعد مسبقًا الثاني في التطبيق! - لتحسين خصوصية البيانات الوصفية. تحسين التنقل في الدردشة اعرض الشروط المُحدثة اقبل الشروط أُضيفت خوادم الرسائل عنوان أو رابط لمرة واحدة؟ مع جهة اتصال واحدة فقط - المشاركة شخصيًا أو عبر أي مُراسل.]]> - سيتم قبول الشروط للمُشغلين المفعّلين بعد 30 يومًا. مُشغلي الخادم لا يمكن تحميل نص الشروط الحالية، يمكنك مراجعة الشروط عبر هذا الرابط: خطأ في قبول الشروط @@ -2171,12 +2160,10 @@ لا يوجد خوادم لاستلام الرسائل. - فتح الدردشة عند أول رسالة غير مقروءة.\n- الانتقال إلى الرسائل المقتبسة. يمكنك تعيين اسم الاتصال، لتتذكر الأشخاص الذين شاركتَ الرابط معهم. - راجع لاحقًا تغيّر بروتوكول الخادم. شارك العنوان علناً شارك عنوان SimpleX على وسائل التواصل الاجتماعي. عنوان SimpleX والروابط لمرة واحدة آمنة للمشاركة عبر أي برنامج مُراسلة. - انقر فوق أنشئ عنوان SimpleX في القائمة لإنشائه لاحقًا. حُذفت هذه الرسالة أو لم تُستلم بعد. استخدم للرسائل يحمي التطبيق خصوصيتك من خلال استخدام مُشغلين مختلفين في كل محادثة. @@ -2207,7 +2194,6 @@ الإشعارات والبطارية فقط مالكي الدردشة يمكنهم تغيير التفضيلات. الخصوصية لعملائك. - الجوّالات عن بُعد ادعُ للدردشة مغادرة المجموعة؟ سيتم إزالة العضو من الدردشة - لا يمكن التراجع عن هذا! @@ -2287,7 +2273,6 @@ بلّغ عن إزعاج (spam): سيراه مُشرفو المجموعة فقط. أرشفة البلاغ؟ سبب الإبلاغ؟ - سيتم أرشفة البلاغ لك. إزعاج (spam) نعم اسأل @@ -2312,7 +2297,6 @@ استخدم منفذ الويب منفذ TCP للمُراسلة اكتم الكل - ذّكورات غير مقروءة يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! السماح بالإبلاغ عن الرسائل إلى المُشرفين. امنع الإبلاغ عن الرسائل للمُشرفين. @@ -2438,7 +2422,6 @@ إرسال طلب جهة اتصال؟ أرسل طلب أرسل طلب دون رسالة - أُرسل إلى جهة اتصالك بعد الاتصال. ترقية رابط المجموعة؟ رقِّ ترقية العنوان؟ @@ -2494,7 +2477,6 @@ طلبات التواصل في المجموعات حُذف العضو - لا يمكن قبول الطلب طُلب اتصال من المجموعة %1$s - هذا الإعداد لملف تعريفك الحالي اسمح بالملفات والوسائط فقط إذا سمح جهة اتصالك بذلك. اسمح لجهات اتصالك بإرسال الملفات والوسائط. روبوت @@ -2526,7 +2508,6 @@ فشل الاتصال فشل ملفات - تصفية صور روابط ابحث عن ملفات @@ -2562,7 +2543,6 @@ قناة اسم القناة بالكامل رابط القناة - أعضاء القناة اسم القناة يُخزّن ملف تعريف القناة على أجهزة المشتركين وعلى مُرحلات الدردشة. حُدِّث ملف تعريف القناة @@ -2590,7 +2570,6 @@ أدخل اسم المُرحل… خطأ في إضافة المُرحل خطأ في إنشاء القناة - خطأ في فتح القناة خطأ: %s خطأ في حفظ ملف تعريف القناة فشل @@ -2610,7 +2589,6 @@ لا مُرحلات دردشة مفعّلة. ليس كل المُرحلات متصلة افتح قناة - افتح قناة جديدة المالك المالكين والمساهمين عنوان المُرحل مسبق الضبط @@ -2779,7 +2757,6 @@ عنوانك العام لقد وُلدت دون حساب تعريفي. قناتك الجديدة %1$s متصلة بـ %2$d من أصل %3$d مُرحلات. إذا ألغيت، ستُحذف القناة - يمكنك إنشاؤها مرة أخرى. - أضف أضف مُرحل أضف مُرحلات ألغِ واحذف القناة @@ -2814,14 +2791,11 @@ الحالة %1$d مالك %1$d مالكون - عن خيارات متقدّمة إعدادات متقدّمة اسمح لأي شخص بالتضمين يمكن لأي صفحة ويب عرض المعاينة. بيانات الدردشة - يتطلب الاتصال عبر اسم القناة إصدارًا أحدث من التطبيق. - يتطلب الاتصال عبر اسم جهة الاتصال إصدارًا أحدث من التطبيق. تواصل مساهم انسخ الرمز @@ -2833,7 +2807,6 @@ سيتم عرضه للمشتركين واستخدامه للسماح بتحميل المعاينة. مزيد من الخصوصية لا يمكن عرض المعاينة إلا على صفحتك المذكورة أعلاه. - يُرجى ترقية التطبيق. %1$d مالكين ومساهمين %1$s دعم تطبيق SimpleX Chat. انتهت صلاحية الشارة في %2$s. قائمة المعترف بهم @@ -2849,8 +2822,6 @@ سيتم تغيير الدور إلى "%s". وسيتم إشعار جميع المشاركين في القناة بذلك. تعذّر التحقق من صحة هذه الشارة، وقد لا تكون أصلية. تتطلب هذه المجموعة إصدارًا أحدث من التطبيق. يُرجى تحديث التطبيق للانضمام إليها. - اسم قناة غير مدعوم - اسم جهة اتصال غير مدعوم شارة غير متحقق منها لا تدعم خوادم ترحيل الدردشة المستعملة صفحات الويب. رمز صفحة الويب @@ -2865,7 +2836,6 @@ خطأ المحلّل: %1$s الخادم %1$s لا يدعم تحليل الأسماء. اضبط الخوادم أو استخدم رابط الاتصال. عيِّن اسم SimpleX - اسم SimpleX خطأ في اسم SimpleX لم يتحقق مِن اسم SimpleX اسم SimpleX %1$s مسجَّل، لكنه لا يحتوي على رابط صالح. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f23224b114..480e448954 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -203,11 +203,6 @@ Please check that you used the correct link or ask your contact to send you another one. Unsupported connection link This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. - Unsupported channel name - Unsupported contact name - Connecting via channel name requires a newer app version. - Connecting via contact name requires a newer app version. - Please upgrade the app. SimpleX name error None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link. Server %1$s does not support name resolution. Configure servers, or use a connection link. @@ -373,7 +368,6 @@ Archive report? Archive %d reports? Archive all reports? - The report will be archived for you. All reports will be archived for you. For me For all moderators @@ -424,7 +418,6 @@ Files Links All messages - Filter Archive Archive report Archive reports @@ -528,7 +521,6 @@ contact should accept… Connect with %1$s? Search or paste SimpleX link - Tap Create SimpleX address in the menu to create it later. No selected chat @@ -726,8 +718,6 @@ Delete chat messages from your device. - connect - open message call search @@ -880,7 +870,6 @@ Unmute Favorite Unfavorite - Unread mentions Create list @@ -987,7 +976,6 @@ Verify name Verify SimpleX names SimpleX name not verified - SimpleX name Your SimpleX name Set SimpleX name Error saving name @@ -1154,7 +1142,6 @@ Use private routing with unknown servers. Use private routing with unknown servers when IP address is not protected. Do NOT use private routing. - Message routing mode Allow downgrade Yes When IP hidden @@ -1162,7 +1149,6 @@ Send messages directly when your or destination server does not support private routing. Send messages directly when IP address is protected and your or destination server does not support private routing. Do NOT send messages directly, even if your or destination server does not support private routing. - Message routing fallback Show message status To protect your IP address, private routing uses your SMP servers to deliver messages. TCP port for messaging @@ -1229,7 +1215,6 @@ Stop sharing Auto-accept Group invitations - Sent to your contact after connection. Welcome message Enter welcome message… (optional) Save settings? @@ -1432,13 +1417,8 @@ When more than one operator is enabled, none of them has metadata to learn who communicates with whom. For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. About operators - Select network operators to use. How it helps privacy You can configure servers via settings. - Conditions will be accepted for enabled operators after 30 days. - You can configure operators in Network & servers settings. - Review later - Update Continue @@ -1579,7 +1559,6 @@ An empty chat profile with the provided name is created, and the app opens as usual. If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode - This setting is for your current profile These settings are for your current profile They can be overridden in contact and group settings. Contacts @@ -1626,7 +1605,6 @@ Files Send delivery receipts to Contact requests in groups - About Contact Support the project Chat data @@ -1660,7 +1638,6 @@ Your chat database Run chat - Remote mobiles Chat is running Chat is stopped Chat database @@ -1794,7 +1771,6 @@ Top bar You can change it in Appearance settings. Show console in new window - Show chat list in new window Invalid migration confirmation Upgrade and open chat Downgrade and open chat @@ -2037,7 +2013,6 @@ Invite Link Chat with admins - Channel members Chat relays @@ -2208,7 +2183,6 @@ Operator %s servers Network operator - Website Conditions accepted on: %s. Conditions will be accepted on: %s. Use servers @@ -2714,7 +2688,6 @@ Network decentralization The second preset operator in the app! Enable Flux in Network & servers settings for better metadata privacy. - for better metadata privacy. Improved chat navigation - Open chat on the first unread message.\n- Jump to quoted messages. Business chats @@ -3175,7 +3148,6 @@ Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. Configure relays failed - Add Add relay Add relays No available relays @@ -3193,12 +3165,10 @@ Relay address This is a chat relay address, it cannot be used to connect. Open channel - Open new channel You are a subscriber You are a contributor Your channel %1$s!]]> - Error opening channel Unblock subscriber for all? @@ -3257,9 +3227,9 @@ Continue How private badges work How private badges work - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + A badge is not an account. It is a signed credential stored on your device. It does not identify you, and no one keeps a record of who holds which badge. + Your contacts see the badge and its expiry date, and nothing else. The badge carries no identifier, so it cannot be used to find out who you are or to match you across chats. + Payment and badge are kept apart. Paying is one step; the badge is issued in another, under a key that exists only for that badge. Whoever handles the payment cannot see where the badge ends up. My nickname Support SimpleX Get badge + files up to 5GB @@ -3268,6 +3238,36 @@ Purchase pending The purchase is awaiting approval. This build does not deliver purchases approved later. Purchase error + Get your code + Redeem code + Paste the code from your receipt. + SB-XXXXX-XXXXX-XXXXX-XXXXX + Redeem + Cannot redeem code + This code is not valid. + This app version cannot redeem badge codes. + This profile already has a badge. Redeem the code on another profile, or once this badge ends. + This code was not recognised. + This code has already been used. + This code has expired. + Too many attempts. Please try again later. + The badge service is unavailable. Please try again later. + The badge service sent an unexpected response. + This app version cannot verify this badge. Please update the app. + This app version is too old for the badge service. Please update the app. + Connection error. Please check your network connection. + The code was accepted, but the badge it grants has already ended. + The code could not be redeemed. + The code you scanned is not a badge code. + Your badge + Support ended + shown on your profile + Ends + Prepaid months have no billing date. The badge is reissued each month from the balance you already paid for, and ends when it runs out. + Investor + Your support ended on %1$s. + Remind me later + Don\'t show again Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c79aa07a1e..75de2d72f1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1780,7 +1780,6 @@ Препращане на съобщенията без файловете? Неизвестни сървъри Файлове - Показване на списъка на чатовете в нов прозорец Системна Тъмна Черна @@ -1833,7 +1832,6 @@ Винаги Не Никога - Режим за рутиране на съобщения Използвай поверително рутиране с неизвестни сървъри, когато IP адресът не е защитен. Светла Да @@ -2053,11 +2051,9 @@ Избрано %d Нищо не е избрано Съобщение - отвори съобщение обаждане Контактът ще бъде изтрит - това не може да бъде отменено! - свързване Изтрий без известие Разговорът е изтрит! Все още ще можете да видите разговора с %1$s в списъка с чатовете. @@ -2125,8 +2121,6 @@ Например, ако контактът ви получава съобщения чрез SimpleX Chat сървър, приложението ви ще ги достави чрез Flux сървър. Сървърни оператори Мрежови оператори - Преглед по-късно - Можете да конфигурирате операторите в Мрежа и сървъри. Условията ще бъдат автоматично приети за активираните оператори на: %s. Вашите сървъри Условията ще бъдат приети на: %s. @@ -2140,16 +2134,13 @@ Добавени сървъри за съобщения Използвай за файлове - Отворете чата при първото непрочетено съобщение.\n- Преминете към цитирани съобщения. - Резервно рутиране на съобщения Активирай логовете Добавете членовете на вашия екип към разговорите. задраскан Продължи Добави приятели %s.]]> - Уеб страница Условия, приети на: %s. - Дистанционни мобилни устройства Предварително зададени сървъри %s.]]> Отвори промените @@ -2160,7 +2151,6 @@ Персонализирана форма на съобщенията. По-добри дати на съобщението. Активиране на Flux в настройките Мрежа и сървъри за по -добра поверителност на метаданните. - за по-добра поверителност на метаданните. Подобрена навигация в чата Грешка при запазване на базата данни Вашият профил ще бъде изпратен до членовете @@ -2179,9 +2169,7 @@ Проверявай за съобщения на всеки 10 минути Приложението винаги работи във фонов режим Известия и батерия - Условията ще бъдат приети за активираните оператори след 30 дни. Можете да конфигурирате сървърите през настройките. - Актуализация %s.]]> Използвай за съобщения Личните съобщения между членовете са забранени. @@ -2195,7 +2183,6 @@ Грешка при зареждането на чат списъка Няма чатове в списъка %s. Няма непрочетени чатове - Докосни Създаване на SimpleX адрес в менюто, за да го създадете по-късно. Няма чатове Няма намерени чатове Любими @@ -2267,7 +2254,6 @@ Връзката изисква предоговаряне на криптирането. Сподели еднократен линк за връзка с приятел Настройки на адреса - Изберете мрежови оператори за използване. Как помага за поверителността Ще спрете да получавате съобщения от този чат. Историята на чата ще бъде запазена. Добави членове на екипа @@ -2455,7 +2441,6 @@ Изпрати заявка Изпрати заявка без съобщение Изпрати личната си обратна връзка до групи. - Изпратено до вашия контакт след осъществяване на връзка. Задаване на име на чат… Задаване на достъп за членове Задаване на срок на валидност на съобщенията в чатовете. @@ -2474,14 +2459,12 @@ Докосни Свързване за изпращане на заявка Докосни Присъединяване към групата TCP порт за съобщения - Докладът ще бъде архивиран за вас. Подателят НЯМА да бъде уведомен. Това действие не може да бъде отменено - съобщенията, изпратени и получени в този чат по-рано от избраното, ще бъдат изтрити. Тази връзка изисква по-нова версия на приложението. Моля, актуализирайте приложението или помолете контакта да ви изпрати съвместим линк. Времето за изчезване е зададено само за нови контакти. За да използвате друг профил след опит за връзка, изтрийте чата и използвайте линка отново. Отблокиране за всички членове? - Непрочетени споменавания Неподдържан линк за връзка Актуализирани условия Актуализирайте адреса си @@ -2508,7 +2491,6 @@ Заявки за контакт от групи Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s - Тази настройка е за текущия профил Разреши файлове и медия само ако вашият контакт ги разрешава. Позволи на вашите контактите да изпращат файлове и медия. Бот diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 3d49c533ff..d5b8fb5fa0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -493,7 +493,6 @@ Confirmeu les credencials Xats Parleu amb els desenvolupadors - connecta Confirmeu l\'eliminació del contacte? Connectat Confirma @@ -740,7 +739,6 @@ Configuració Tanca s\'està connectant… - Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. Trucades de SimpleX Chat Missatges de xat de SimpleX @@ -1006,7 +1004,6 @@ Fitxers Correcció no suportada pel contacte Arreglar - per a una millor privadesa de les metadades. Bona tarda! El fitxer exportat no existeix Reenvia i desa missatges @@ -1090,7 +1087,6 @@ Micròfon Trucada perduda Esborrany - Mòbils remots Elimina Expulsa t\'ha expulsat @@ -1226,9 +1222,6 @@ L\'aplicació protegeix la teva privadesa utilitzant diferents operadors en cada conversa. Quan hi ha més d\'un operador habilitat, cap d\'ells té metadades per saber qui es comunica amb qui. Com ajuda a la privadesa - Revisar més tard - Actualitzar - Podeu configurar els operadors a la configuració: Xarxa i Servidors. videotrucada (sense xifrar) Ignorar Rebutjar @@ -1307,7 +1300,6 @@ El rol es canviarà a %s. El membre rebrà una nova invitació. Estat de la xarxa Operador - Lloc web Per a enviar Restablir valors predeterminats Compte PING @@ -1746,7 +1738,6 @@ El vostre contacte ha enviat un fitxer més gran que la mida màxima admesa actualment (%1$s). Carregant el fitxer Notificacions - obrir Si us plau, espereu mentre es carrega el fitxer des del mòbil enllaçat Error de fitxer temporal Missatge de veu @@ -1905,8 +1896,6 @@ Si disponibles Les vostres credencials es podrien enviar sense xifrar. Colors de la interfície - Alternativa d\'encaminament de missatges - Mode d\'encaminament de missatges Obrir ubicació del fitxer Envieu missatges directament quan l\'adreça IP està protegida i el vostre servidor de destinació no admet l\'encaminament privat. Enviar missatges directament quan el vostre servidor de destinació no admet l\'encaminament privat. @@ -1936,7 +1925,6 @@ Operadors de xarxa Notificacions i bateria La contrasenya aleatòria s\'emmagatzema a la configuració com a text pla.\nPodeu canviar-ho més tard. - Seleccioneu els operadors de xarxa que voleu utilitzar. Operadors de servidor Configura la contrasenya de la base de dades Utilitzar contrasenya aleatòria @@ -1978,7 +1966,6 @@ No s\'ha trobat la contrasenya a Keystore, introduïu-la manualment. Això pot haver passat si vau restaurar les dades de l\'aplicació mitjançant una eina de còpia de seguretat. Si no és el cas, poseu-vos en contacte amb els desenvolupadors. Barres d\'eines d\'aplicacions accessible Barres d\'eines de xat accessible - Mostra la llista de xat en una finestra nova Mostrar consola en finestra nova Podeu canviar-la a la configuració de l\'aparença. Actualitzar i obrir el xat @@ -2135,7 +2122,6 @@ IU en hongarès i turc Codi d\'accés no canviat! Protegiu la vostra adreça IP dels servidors de retransmissió de missatgeria escollits pels vostres contactes.\nActiva a la configuració de *Xarxa i servidors*. - Toqueu Crea adreça SimpleX al menú per crear-la més tard. Per protegir la zona horària els fitxers d\'imatge/veu utilitzen UTC. Desbloca La càrrega ha fallat @@ -2262,7 +2248,6 @@ Contingut inadequat Perfil inadequat Suprimeix l\'informe - L\'informe s\'arxivarà. Informar Spam El contingut infringeix les condicions d\'ús @@ -2300,7 +2285,6 @@ Emprar port web Emprar el port TCP %1$s quan no se n\'especifica cap. Silenciar tot - Mencions no llegides Podeu mencionar fins a %1$s membres per missatge! Permetre denunciar missatges a moderació. No permetre denunciar missatges a moderació. @@ -2427,7 +2411,6 @@ Enviar sol·licitud de contacte? Enviar sol·licitud Enviar sol·licitud sense missatge - Enviat al contacte després de la connexió. Actualitzar l\'enllaç del grup? Actualitzar Actualitzar l\'adreça? @@ -2482,7 +2465,6 @@ Sol·licituds de contacte de grups Membre eliminat(da); no es pot acceptar la sol·licitud. connexió sol·licitada del grup %1$s - Aquesta configuració és per al perfil actual Permetre fitxers i contingut multimèdia només si el vostre contacte ho permet. Permetre que els contactes enviïn fitxers i contingut multimèdia. Bot diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index ff712dfec5..f8a6dd83dc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1781,8 +1781,6 @@ Nelze odeslat zprávu Chyba souboru NEpoužívat soukromé směrování. - Záložní směrování zpráv - Režim přeposílání zpráv Nikdy Chyba inicializace WebView. Aktualizujte systém na novou verzi. Prosím kontaktujte vývojáře. \nChyba: %s @@ -1937,7 +1935,6 @@ Lepší datování zpráv. Lepší zabezpečení ✅ Části odstraněny - připojení Aktuální profil Chyba znovu připojení serveru Chyba při opětovném připojování serverů @@ -1949,7 +1946,6 @@ Chyba přepínání profilu Vyberte profil chatu zpráva - otevřít Kontakt smazán! neaktivní Detaily @@ -2058,7 +2054,6 @@ Žádné servery pro příjem zpráv. Všechny chaty budou ze seznamu %s odebrány, a seznam bude smazán Pro sociální sítě - Vzdálené telefony %s.]]> %s.]]> %s.]]> @@ -2144,7 +2139,6 @@ Panel nástrojů aplikace Rozmazání Přímé zprávy mezi členy jsou zakázány. - pro lepší ochranu metadat. Chat již existuje Přes proxy Firemní chaty @@ -2191,7 +2185,6 @@ %s.]]> %1$s.]]> Relace aplikace - Podmínky budou přijaty pro povolené operátory po 30 dnech. Připojení vyžaduje opětovné vyjednání šifrování. Probíhá opětovné vyjednávání o šifrování. Opravit @@ -2206,7 +2199,6 @@ Použijte náhodné přihlašovací údaje Nahlásit spam: uvidí pouze skupinový moderátoři. Přečíst podmínky - Webové stránky Odebírán požádáno o připojení Spam @@ -2222,24 +2214,20 @@ Nedoručené zprávy Tato zpráva byla smazána, nebo dosud nebyla přijata. Důvod nahlášení? - Report bude archivován. Zprávy budou pro všechny členy označeny jako moderované. Reporty Nahlašte profil člena: uvidí pouze skupinový moderátoři. Nahlásit porušení: Uvidí pouze moderátoři skupiny. Zobrazit stav zpráv Přeskočit tuto verzi - Zobrazit seznam v novém okně Aktualizovat aplikaci automaticky Některé soubory nebyly exportovány - Nepřečtené zmínky Sdílet adresu veřejně Sdílent SimpleX adresu na sociálních médiích. Sdílejte 1 rázový odkaz s přítelem SOCKS Proxy Dostupná aktualizace: %s Stabilní - Můžete nastavit operátory v nastavení sítě a serverů. Ocas Zastavíte přijímání zpráv z tohoto chatu. Chat historie bude zachována. Servery pro nové soubory vašeho aktuálního profilu chatu @@ -2274,7 +2262,6 @@ Nahrané soubory Odběr ignorován Verze serveru není kompatibilní s nastavením sítě. - Pro pozdější vytvoření adresy, klepněte v menu na Vytvořit SimpleX adresu. Vaše servery Zobrazit aktualizované podmínky 1 report @@ -2301,8 +2288,6 @@ Chyby nahrávání Soukromé směrování se používá k doručování zpráv těmto serverům, protože k nim nejste připojeni. Velikost - Přečíst později - Aktualizovat %s servery Celkem Verze serveru není kompatibilní s vaší aplikací: %1$s. @@ -2319,7 +2304,6 @@ Vaše kontakty Od %s.\nJsou všechna data uchovávána ve vašem zařízení. Serverový operátoři - Vybrat provozovatele sítě pro použití. Můžete nakonfigurovat servery v nastavení. Zvuk ztlumen Používat aplikaci jednou rukou. @@ -2496,7 +2480,6 @@ Odeslat žádost Odeslat žádost bez zprávy Posílání soukromé zpětné vazby do skupin. - Pošlete kontaktu po připojení. Nastavení bio profilu a uvítací zprávy. Sdílení staré adresy Sdílení starého odkazu @@ -2511,7 +2494,6 @@ Adresa bude krátká a Váš profil bude sdílen prostřednictvím adresy. Odkaz bude krátký a profil skupiny bude sdílen prostřednictvím odkazu. Odesílatel NEBUDE informován. - Toto nastavení je pro váš aktuální profil Čas mizení, je nastaven pouze pro nové kontakty. Pro odeslání příkazů musíte být připojen. Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. @@ -2532,7 +2514,6 @@ Smazat zprávy člena? Smazat zprávy Soubory - Filtr Obrázky Odkazy Zprávy člena budou smazány - nelze zrušit! @@ -2582,7 +2563,6 @@ Kanál nemá aktivní relé.\nProsím, zkuste se připojit později. Odkaz kanálu Odkaz kanálu - Členové kanálu Název kanálu Vlastnosti kanálu kanál @@ -2637,7 +2617,6 @@ Chyba Chyba přidávání relé Chyba vytváření kanálu - Chyba otevírání kanálu chyba: %s Chyba ukládání profilu kanálu Chyba sdílení kanálu @@ -2657,7 +2636,6 @@ Připojit ke kanálu Opustit kanál Opustit kanál? - Přidat Přidat relé Přidat relé Povolit odběratelům odesílání přímých zpráv. @@ -2709,7 +2687,6 @@ Na vašem telefonu, ne na serverech. Otevřít kanál Otevřít externí odkaz? - Otevřít nový kanál Nebo ukažte QR osobně nebo prostřednictvím videohovoru. Nebo použijte tento QR kód - tisk nebo zobrazit online. MAJITEL @@ -2776,7 +2753,6 @@ %1$d vlastníci %1$d vlastníků & přispěvatelů %1$s podpořilo SimpleX Chat. Odznak prošel %2$s. - O aplikaci odmítnuto odmítnuto operátorem relé Pokročilé možnosti diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 82f8ae5770..8c7d92086c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -438,7 +438,6 @@ Arkivér rapport? Arkivér %d rapporter? Arkivér alle rapporter? - Rapporten vil blive arkiveret for dig. For mig Til alle moderatorer Fejl: %1$s @@ -547,7 +546,6 @@ kontakten skal acceptere… Forbinde med %1$s? Søg eller indsæt SimpleX-link - Tryk på Opret SimpleX-adresse i menuen for at oprette den senere. Ingen chat valgt Intet er valgt Valgte %d @@ -679,8 +677,6 @@ Denne handling kan ikke fortrydes - beskeder sendt og modtaget i denne chat tidligere end valgt vil blive slettet. Deaktiver sletning af beskeder Slet chatbeskeder fra din enhed. - forbinde - åbn besked opkald søg @@ -874,7 +870,6 @@ %1$d ejer %1$d ejere aktiv - Tilføj Tilføj relay Tilføj relays Tilføj relays for at genoprette beskedleveringen. @@ -894,7 +889,6 @@ %1$d abonnent %1$d abonnenter %1$s støttede SimpleX Chat. Mærket udløb den %2$s. - Om accepteret Tilføj bidragsydere. Tilføj beskrivelse @@ -925,11 +919,6 @@ Fingeraftrykket i serveradressen matcher ikke certifikatet: %1$s. Fingeraftrykket i destinations-serveradressen matcher ikke certifikatet: %1$s. Fejl ved markering som læst - Ikke-understøttet navn på kanal - Ikke-understøttet navn på kontakt - Forbindelse gennem kanal-navnet kræver en nyere version af appen. - Forbindelse gennem kontakt-navnet kræver en nyere version af appen. - Opgrader appen. Fejl i SimpleX-navn Ingen af dine servere er sat op til at slå SimpleX-navne op. Konfigurer serverne, eller anvend et forbindelseslink. Navn ikke fundet diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index a057fa5275..f8fa2dbd66 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1848,11 +1848,9 @@ Ungeschützt Bei unbekannten Servern privates Routing nutzen. KEIN privates Routing nutzen. - Modus für das Nachrichten-Routing Ja Nein Wenn die IP-Adresse versteckt ist - Fallback für das Nachrichten-Routing Nachrichtenstatus anzeigen Herabstufung erlauben Immer privates Routing nutzen. @@ -1893,7 +1891,6 @@ Chat-Farben Chat-Design Passend - Chat-Liste in einem neuen Fenster anzeigen Guten Nachmittag! Guten Morgen! Farben für die dunkle Variante @@ -2058,9 +2055,7 @@ Stark Mittel Aus - Verbinden Nachricht - Öffnen Chat-Inhalte gelöscht! Nur die Chat-Inhalte löschen Suchen @@ -2209,14 +2204,9 @@ Server-Betreiber Netzwerk-Betreiber Die App verwendet für jede Unterhaltung Server von unterschiedlichen Betreibern, um Ihre Privatsphäre zu schützen. - Die Nutzungsbedingungen der aktivierten Betreiber werden nach 30 Tagen akzeptiert. Wenn Ihr Kontakt beispielsweise Nachrichten über einen SimpleX-Chat-Server empfängt, wird Ihre App diese über einen Flux-Server versenden. - Später einsehen - Wählen sie die zu nutzenden Netzwerk-Betreiber aus. - Sie können die Betreiber in den Netzwerk- und Servereinstellungen konfigurieren. Sie können die Server über die Einstellungen konfigurieren. Weiter - Aktualisieren Voreingestellte Server Nutzungsbedingungen einsehen Die Nutzungsbedingungen der aktivierten Betreiber werden am %s automatisch akzeptiert. @@ -2225,7 +2215,6 @@ %s Server Netzwerk-Betreiber Verwende Server - Webseite Verwende %s %s.]]> %s.]]> @@ -2267,12 +2256,10 @@ %s zu nutzen, müssen Sie dessen Nutzungsbedingungen akzeptieren.]]> Fehler beim Akzeptieren der Nutzungsbedingungen Fehler beim Aktualisieren des Servers - für einen besseren Metadatenschutz. Neuer Server Keine Nachricht Keine Server für privates Nachrichten-Routing. Die Adresse öffentlich teilen - Tippen Sie im Menü auf SimpleX-Adresse erstellen, um sie später zu erstellen. Diese Verbindung hat das Limit der nicht ausgelieferten Nachrichten erreicht. Ihr Kontakt ist möglicherweise offline. Diese Nachricht wurde gelöscht oder bisher noch nicht empfangen. Zum Schutz vor dem Austausch Ihres Links können Sie die Sicherheitscodes Ihrer Kontakte vergleichen. @@ -2280,7 +2267,6 @@ %s.]]> Die Nutzungsbedingungen wurden am %s akzeptiert. Der Text der aktuellen Nutzungsbedingungen konnte nicht geladen werden. Sie können die Nutzungsbedingungen unter diesem Link einsehen: - Ferngesteuerte Mobiltelefone Oder importieren Sie eine Archiv-Datei Hinweis für Geräte von Xiaomi: Bitte aktivieren Sie in den System-Einstellungen die Option "Autostart", damit Benachrichtigungen funktionieren.]]> Ende-zu-Ende-verschlüsselt versendet. In Direktnachrichten sogar mit Post-Quantum-Security.]]> @@ -2372,7 +2358,6 @@ Spam Archiv Grund der Meldung? - Die Meldung wird für Sie archiviert. Mitgliederprofil melden: Nur Gruppenmoderatoren werden es sehen. Anderes melden: Nur Gruppenmoderatoren werden es sehen. Verstoß melden: Nur Gruppenmoderatoren werden es sehen. @@ -2409,7 +2394,6 @@ TCP-Port für Nachrichtenübermittlung Sie können bis zu %1$s Mitglieder pro Nachricht erwähnen! Alle stummschalten - Ungelesene Erwähnungen Melden von Nachrichten ist in dieser Gruppe nicht erlaubt. Alle Meldungen archivieren? Archiviere %d Meldungen? @@ -2534,7 +2518,6 @@ Kontakt-Anfrage senden? Anfrage senden Anfrage ohne Nachricht senden - Wird nach der Verbindung an Ihren Kontakt gesendet. Gruppen-Link aktualisieren? Aktualisieren Adresse aktualisieren? @@ -2590,7 +2573,6 @@ Kontaktanfragen in Gruppen Mitglied ist gelöscht - Anfrage kann nicht angenommen werden Angefragte Verbindung von Gruppe %1$s - Diese Einstellung gilt für Ihr aktuelles Profil Erlauben Sie Dateien und Medien nur dann, wenn es Ihr Kontakt ebenfalls erlaubt. Erlauben Sie Ihren Kontakten Dateien und Medien zu senden. Bot @@ -2620,7 +2602,6 @@ Mitglied entfernen und Nachrichten löschen Alle Nachrichten Dateien - Filter Bilder Links Dateien suchen @@ -2650,7 +2631,6 @@ Kanal Kanal Kanallink - Kanal-Mitglieder Kanalname Der Kanal wird für alle Abonnenten gelöscht. Dies kann nicht rückgängig gemacht werden! Der Kanal wird für Sie gelöscht. Dies kann nicht rückgängig gemacht werden! @@ -2680,7 +2660,6 @@ Relais-Name eingeben… Fehler beim Hinzufügen des Relais Fehler beim Erstellen des Kanals - Fehler beim Öffnen des Kanals Fehlgeschlagen Fehlgeschlagen Link erhalten @@ -2697,7 +2676,6 @@ Es sind keine Chat-Relais aktiviert. Es sind nicht alle Relais verbunden Kanal öffnen - Neuen Kanal öffnen Eigentümer Eigentümer und Mitwirkende Voreingestellte Relais-Adresse @@ -2876,7 +2854,6 @@ Die Linkvorschau wird über einen SOCKS-Proxy angefordert. DNS-Abfragen können dennoch lokal über Ihren DNS-Resolver erfolgen. Obere Leiste Ihr neuer Kanal %1$s ist mit %2$d von %3$d Relais verbunden.\nBei Abbruch, wird der Kanal gelöscht. Sie können ihn später neu erstellen. - Hinzufügen Relais hinzufügen Relais hinzufügen Kanal abbrechen und löschen @@ -2913,7 +2890,6 @@ %1$d Eigentümer %1$d Eigentümer und Mitwirkende %1$s hat SimpleX Chat unterstützt. Das Abzeichen ist am %2$s abgelaufen. - Über Bestätigter Relaisbestand Fügen Sie diesen Code in Ihre Webseite ein. Er zeigt die Vorschau Ihres Kanals / Ihrer Gruppe an. Erweiterte Optionen @@ -2924,8 +2900,6 @@ Abzeichen ist nicht verifizierbar Kanal-Webseite Chat-Daten - Die Verbindung über den Kanalnamen erfordert eine neuere App‑Version. - Die Verbindung über den Kontaktnamen erfordert eine neuere App‑Version. Kontakt Mitwirkender Code kopieren @@ -2937,7 +2911,6 @@ Dies wird Abonnenten angezeigt und zum Laden der Vorschau genutzt. Weitere Privatsphäre Nur Ihre oben genannte Seite kann die Vorschau anzeigen. - Bitte die App aktualisieren. %s hat sich am SimpleX Chat-Crowdfunding beteiligt. %s unterstützt SimpleX Chat. Abonnent @@ -2946,8 +2919,6 @@ Die Rolle wird auf %s geändert. Alle Abonnenten werden benachrichtigt. Dieses Abzeichen konnte nicht verifiziert werden und ist möglicherweise nicht echt. Diese Gruppe erfordert eine neuere App‑Version. Bitte aktualisieren Sie die App, um beizutreten. - Kanalname wird nicht unterstützt - Kontaktname wird nicht unterstützt Abzeichen nicht verifiziert Die verwendeten Chat‑Relais unterstützen keine Webseiten. Webseiten-Code @@ -2962,7 +2933,6 @@ Namensauflösungs-Fehler: %1$s Der Server %1$s unterstützt keine Namensauflösung. Konfigurieren Sie Server oder verwenden Sie einen Verbindungslink. SimpleX-Name einrichten - SimpleX-Name Fehler beim SimpleX-Namen SimpleX-Name ist nicht verifiziert Der SimpleX-Name %1$s wurde registriert, aber er hat keinen gültigen Link. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 73abd7d876..66cd9cf03b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -543,7 +543,6 @@ Ολοκληρωμένο Οι όροι έγιναν αποδεκτοί στις: %s. Όροι χρήσης - Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. Οι όροι θα γίνουν αποδεκτοί στις: %s. Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. Διαμορφωμένοι SMP διακομιστές @@ -560,7 +559,6 @@ Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. Επιβεβαίωση μεταφόρτωσης Επιβεβαίωση των διαπιστευτηρίων σου - σύνδεση Σύνδεση Σύνδεση Σύνδεση @@ -925,7 +923,6 @@ Αντιστροφή κάμερας Μέγεθος γραμματοσειράς Για όλους τους διαχειριστές - για καλύτερη ιδιωτικότητα μεταδεδομένων Για το προφίλ συνομιλίας %s: Για κονσόλα Για όλους @@ -1216,8 +1213,6 @@ Απαγορεύονται οι αντιδράσεις στα μηνύματα. Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. Λήψη μηνυμάτων - Εναλλακτική δρομολόγηση μηνυμάτων - Λειτουργία δρομολόγησης μηνυμάτων Μηνύματα Μηνύματα και αρχεία Διακομιστές μηνυμάτων @@ -1289,7 +1284,6 @@ Πάτα Σύνδεση για να συνομιλήσεις Πάτα Σύνδεση για αποστολή αιτήματος Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ - Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. Πάτα Συμμετοχή στην ομάδα Πάτα για να ενεργοποιήσεις το προφίλ. Πάτα για Σύνδεση @@ -1341,7 +1335,6 @@ Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. Το προφίλ κοινοποιείται μόνο στις επαφές σου. - Η αναφορά θα αρχειοθετηθεί για εσένα. Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. @@ -1375,7 +1368,6 @@ Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. Αυτός ο κωδικός QR δεν είναι σύνδεσμος! Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. - Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. Αυτό το κείμενο δεν είναι σύνδεσμος! Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή @@ -1438,11 +1430,9 @@ Απενεργοποίηση σίγασης Απροστάτευτο αδιάβαστο - Μη αναγνωσμένες αναφορές Μη υποστηριζόμενος σύνδεσμος σύνδεσης Αναβάθμιση Αναβάθμιση - Αναβάθμιση Διαθέσιμη αναβάθμιση: %s Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων Ενημερωμένοι όροι @@ -1737,7 +1727,6 @@ Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. - άνοιγμα Άνοιξε Άνοιγμα Άνοιξε τις ρυθμίσεις της εφαρμογής @@ -1955,7 +1944,6 @@ Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. Υπενθύμιση αργότερα - Απομακρυσμένα κινητά τηλέφωνα Κατάργηση Κατάργηση Κατάργηση και διαγραφή μηνυμάτων @@ -2025,7 +2013,6 @@ Προϋποθέσεις ελέγχου ελέγχθηκε από τους διαχειριστές Έλεγχος μελών ομάδας - Έλεγχος αργότερα Έλεγχος μελών Έλεγχος μελών πριν την αποδοχή τους (knocking). Ανάκληση @@ -2088,7 +2075,6 @@ Επέλεξε επαφές Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. Επιλέχθηκαν %d - Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. Αυτοκαταστροφή Κωδικός αυτοκαταστροφής Κωδικός αυτοκαταστροφής @@ -2140,7 +2126,6 @@ Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. Απεσταλμένη απάντηση Σύνολο απεσταλμένων - Αποστέλλεται στην επαφή σου μετά τη σύνδεση. Αποστολή μέσω διακομιστή μεσολάβησης Διακομιστής Ο διακομιστής προστέθηκε στο χειριστή %s. @@ -2204,7 +2189,6 @@ Σύντομη διεύθυνση SimpleX Εμφάνιση Εμφάνιση: - Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο Εμφάνιση επαφής και μηνύματος Εμφάνιση επιλογών για προγραμματιστές @@ -2377,7 +2361,6 @@ Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! Διακομιστές WebRTC ICE - Ιστοσελίδα Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. εβδομάδες Καλωσόρισες! @@ -2437,7 +2420,6 @@ είσαι παρατηρητής μπλόκαρες %s Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. - Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. Μπορείς να το δημιουργήσεις αργότερα @@ -2513,7 +2495,6 @@ Μεγέθυνση Όλα τα μηνύματα Αρχεία - ΦΙλτράρισμα Εικόνες Σύνδεσμοι Αναζήτηση αρχείων diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 9274b7a30d..5ba89e4df5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1761,8 +1761,6 @@ Enrutamiento privado Servidores desconocidos NO usar enrutamiento privado. - Enrutamiento de mensajes alternativo - Modo de enrutamiento de mensajes No Estado del mensaje Usar enrutamiento privado con servidores desconocidos cuando tu dirección IP no está protegida. @@ -1816,7 +1814,6 @@ Color adicional 2 Configuración avanzada Tema del perfil - Listado del chat en ventana nueva Color imagen de fondo información cola del servidor: %1$s \n @@ -1984,13 +1981,11 @@ Suave Barra de herramientas accesible llamar - conectar ¿Eliminar %d mensajes de miembros? mensaje Mensaje Nada seleccionado Los mensajes se marcarán para eliminar. El destinatario o destinatarios podrán revelar estos mensajes. - abrir Seleccionar Seleccionados %d Configuración @@ -2123,8 +2118,6 @@ ¿Dirección SimpleX o enlace de un solo uso? Operadores de servidores Operadores de red - Las condiciones de los operadores habilitados serán aceptadas después de 30 días. - Revisar más tarde Condiciones aceptadas el: %s. Operador de red Operador @@ -2156,7 +2149,6 @@ Activa Flux en Servidores y Red para mejorar la privacidad de los metadatos. Error al aceptar las condiciones Error al actualizar el servidor - para mejorar la privacidad de los metadatos. Ningún mensaje Servidor nuevo Sin servidores para archivos y multimedia. @@ -2164,11 +2156,8 @@ Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado - Selecciona operadores a usar. Compartir dirección públicamente Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. - Actualizar - Sitio web Tus servidores Usar %s Usar servidores @@ -2183,7 +2172,6 @@ solamente con un contacto - comparte en persona o mediante cualquier aplicación de mensajería.]]> Puedes añadir un nombre a la conexión para recordar a quién corresponde. La aplicación protege tu privacidad mediante el uso de diferentes operadores en cada conversación. - Puedes configurar los operadores desde los ajustes de Servidores y Red. %s.]]> %s.]]> %s.]]> @@ -2198,10 +2186,8 @@ %s.]]> %s.]]> Por ejemplo, si tu contacto recibe a través de un servidor de SimpleX Chat, tu aplicación enviará a través de un servidor de Flux. - Pulsa Crear dirección SimpleX en el menú para crearla más tarde. La conexión ha alcanzado el límite de mensajes no entregados. es posible que tu contacto esté desconectado. El mensaje ha sido eliminado o aún no se ha recibido. - Móvil remoto O importa desde un archivo Mensajes directos entre miembros de este chat no permitidos. En dispositivos Xiaomi: por favor, habilita el Autoinicio en los ajustes del sistema para que las notificaciones funcionen.]]> @@ -2281,7 +2267,6 @@ Error al guardar la configuración informe archivado por %s ¿Archivar informe? - El informe será archivado para ti. Informar Eliminar informe 1 informe @@ -2329,7 +2314,6 @@ Se usa el puerto TCP %1$s cuando no se ha especificado otro. Usar puerto web Silenciar todo - Menciones sin leer ¡Puedes mencionar hasta %1$s miembros por mensaje! Los miembros pueden informar de mensajes a los moderadores. No se permite informar de mensajes a los moderadores. @@ -2466,7 +2450,6 @@ Enviar solicitud Enviar solicitud sin mensaje Envía tu comentario privado a los grupos. - Enviadp a tu contacto tras la conexión. ¿Actualizar enlace de grupo? Actualizar ¿Actualizar la dirección? @@ -2510,7 +2493,6 @@ Actualizar enlace de grupo Solicitudes de contacto en grupos conexión solicitada desde el grupo %1$s - Esta configuración se aplica al perfil actual Miembro eliminado, no puede aceptar solicitudes Se permiten archivos y multimedia pero sólo si tu contacto también los permite. Permes que tus contactos envíen archivos y multimedia. @@ -2541,7 +2523,6 @@ Eliminar miembro y sus mensajes Todos los mensajes Archivos - Filtro Imágenes Enlaces Buscar archivos @@ -2592,7 +2573,6 @@ Canal Título completo: Enlace del canal - Miembros canal Título del canal perfil del canal actualizado El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! @@ -2608,7 +2588,6 @@ caído (%1$d intentos) Editar perfil del canal Error al crear el canal - Error al abrir el canal error:%s Error al guardar el perfil del canal fallo @@ -2629,7 +2608,6 @@ Ningún servidor de chat activado. Hay servidores no conectados Abrir canal - Abrir canal nuevo Propietario Propietarios y colaboradores Direcciones predefinidas @@ -2804,7 +2782,6 @@ Las previsualizaciones de enlaces se solicitan a través del proxy SOCKS. Las peticiónes DNS aún pueden usar el DNS local del sistema. Menú superior Tu nuevo canal %1$s está conectado a %2$d de %3$d servidores.\nSi cancelas, el canal será eliminado. Puedes crearlo de nuevo. - Añadir Añadir servidor Añadir servidores Cancelar y eliminar el canal @@ -2843,7 +2820,6 @@ Se mostrará a los suscriptores y se usará para permitir la carga de la vista previa. Más privacidad Solo la página superior puede mostrar la vista previa. - Por favor, actualiza la aplicación. rechazado rechazado por el operador del servidor %s ha participado en la financiación colectiva de SimpleX Chat. @@ -2858,7 +2834,6 @@ %1$d propietarios %1$d propietarios y colaboradores %1$s ha apoyado a SimpleX Chat. La insignia caducó el %2$s. - Acerca de lista confirmada Añade este código a tu web. Mostrará una vista previa de tu canal o grupo. Opciones avanzadas @@ -2871,10 +2846,6 @@ No se pudo verificar la insignia Web del canal Datos del chat - Nombre de canal no compatible - Nombre de contacto no compatible - Para conectarte mediante el nombre del canal es necesaria una versión más reciente de la aplicación. - Para conectarse mediante el nombre de un contacto es necesaria una versión más reciente de la aplicación. Este grupo requiere una versión más reciente de la aplicación. Por favor, actualizala para unirte. Código web Los servidores usados no admiten páginas web. @@ -2892,7 +2863,6 @@ Error de resolución: %1$s El servidor %1$s no admite la resolución de nombres. Configura un servidor, o usa un enlace para conectarte. Escribe el nombre SimpleX - Nombre SimpleX Error del nombre SimpleX Nombre SimpleX no verificado El nombre SimpleX %1$s está registrado, pero no tiene un enlace válido. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index b65fc5ab93..0051298873 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -1576,10 +1576,8 @@ هرگز استفاده از مسیریابی خصوصی با سرورهای ناشناخته. استفاده از مسیریابی خصوصی با سرورهای ناشناخته وقتی نشانی IP محافظت نشده است. - حالت مسیریابی پیام بله پیام‌ها مستقیما فرستاده نشود، حتی اگر سرور مقصد شما از مسیریابی خصوصی پشتیبانی نکند. - گزینه پس‌رفت مسیریابی پیام نمایش وضعیت پیام برای محافظت از نشانی IP شما، مسیریابی خصوصی از سرورهای SMP شما به منظور تحویل پیام‌ها استفاده می‌کند. مسیریابی پیام خصوصی @@ -1797,7 +1795,6 @@ اطلاعات صف سرور: %1$s \n \nآخرین پیام دریافتی: %2$s - نمایش فهرست چت در پنجره جدید حالت تاریک سیاه حالت رنگ @@ -1972,7 +1969,6 @@ برای مجوز میکروفون، روی آیکون اطلاعات کنار نوار آدرس کلیک کنید تکمیل شد تایید حذف مخاطب؟ - اتصال اتصال متصل اتصال سریع تر! 🚀 @@ -2049,7 +2045,6 @@ رد شده رد کردن عضو؟ بعداً یادآوری کن - موبایل‌های از راه دور حذف کردن آرشیو؟ از گروه حذف شد اعضا حذف شوند؟ @@ -2075,7 +2070,6 @@ بررسی شرایط بررسی شده توسط مدیران بررسی اعضای گروه - بعداً بررسی شود بررسی اعضا بررسی اعضا قبل از پذیرش (کوبیدن). آیا تنظیمات پذیرش ذخیره شود؟ @@ -2089,7 +2083,6 @@ انتخاب پروفایل چت تنظیمات برگزیده چت، این پیام را ممنوع می‌کند. %d انتخاب شده - اپراتورهای شبکه را برای استفاده انتخاب کنید. تنظیم پذیرش اعضا تنظیم انقضای پیام در چت‌ها. تنظیمات @@ -2131,7 +2124,6 @@ انتها برای چت، روی \"اتصال\" ضربه بزنید. برای ارسال درخواست، روی \"اتصال\" ضربه بزنید. - برای ایجاد آدرس SimpleX در آینده، در منو روی \"ایجاد آدرس SimpleX\" ضربه بزنید. روی \"پیوستن به گروه\" ضربه بزنید. اتصال TCP وقفه زمانی اتصال TCP در پس‌زمینه @@ -2181,7 +2173,6 @@ بخش‌ها بارگذاری شدند شرایط در %s پذیرفته شد. شرایط استفاده - شرایط برای اپراتورهای فعال پس از ۳۰ روز پذیرفته خواهد شد. شرایط در: %s پذیرفته خواهد شد. شرایط به‌طور خودکار برای اپراتورهای فعال در: %s پذیرفته خواهد شد. سرورهای SMP پیکربندی‌شده @@ -2307,7 +2298,6 @@ خاموش فقط مالکین چت می‌توانند تنظیمات را تغییر دهند. فقط گفتگو حذف شود - باز کردن باز کردن تغییرات - باز کردن چت برای اولین پیام خوانده نشده.\n- پرش به پیام‌های نقل‌قول شده. شرایط باز کردن @@ -2354,7 +2344,6 @@ اتصال اصلاح شود؟ اندازه فونت برای همه مدیران - برای بهبود حریم خصوصی فراداده. به عنوان مثال، اگر مخاطب شما پیام‌ها را از طریق یک سرور چت SimpleX دریافت کند، برنامه شما آن‌ها را از طریق یک سرور Flux تحویل خواهد داد. برای من برای مسیریابی خصوصی @@ -2407,7 +2396,6 @@ مستقیم ارسال شد پیام‌های ارسال شده تعداد کل ارسال شده - پس از اتصال به مخاطب شما ارسال شد. توسط پروکسی ارسال شد سرور سرور به اپراتور %s اضافه شد. @@ -2418,7 +2406,6 @@ اطلاعات سرورها آمار سرورها بازنشانی خواهد شد - این عمل غیرقابل بازگشت است! نام چت را تنظیم کنید… - گزارش برای شما بایگانی خواهد شد. نقش به %s تغییر خواهد کرد. همه در چت مطلع خواهند شد. دومین اپراتور پیش‌فرض در برنامه! فرستنده مطلع نخواهد شد. @@ -2437,8 +2424,6 @@ شفافیت جلسات Transport رفع انسداد اعضا برای همه؟ - منشن‌های خوانده نشده - به‌روزرسانی به‌روزرسانی موجود است: %s شرایط به‌روزرسانی شده دانلود به‌روزرسانی لغو شد @@ -2461,7 +2446,6 @@ ویدئو مشاهده شرایط مشاهده شرایط به‌روزرسانی شده - وب‌سایت پیام خوش‌آمدگویی زمانی که بیش از یک اپراتور فعال است، هیچ‌کدام از آن‌ها فراداده‌ای برای فهمیدن اینکه چه کسی با چه کسی ارتباط برقرار می‌کند، ندارند. سرور XFTP @@ -2469,7 +2453,6 @@ شما این عضو را پذیرفتید شما به این سرورها متصل نیستید. از مسیریابی خصوصی برای ارسال پیام‌ها به آن‌ها استفاده می‌شود. شما می‌توانید آن را در تنظیمات ظاهر تغییر دهید. - شما می‌توانید اپراتورها را در تنظیمات شبکه و سرورها پیکربندی کنید. شما می‌توانید سرورها را از طریق تنظیمات پیکربندی کنید. شما می‌توانید پیام را کپی کرده و اندازه آن را کاهش دهید تا ارسال شود. شما می‌توانید در هر پیام تا %1$s عضو را منشن کنید! @@ -2510,7 +2493,6 @@ ۴ زبان جدید رابط کاربری کاتالان، اندونزیایی، رومانیایی و ویتنامی - با تشکر از کاربران ما! عضو حذف شده است - نمی‌توان درخواست را قبول کرد - این تنظیمات برای پروفایل فعلی شماست درخواست‌های تماس از گروه‌ها درخواست اتصال از گروه %1$s برای استفاده از ربات باز کنید diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index bfbe32e819..572b10c988 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1764,13 +1764,11 @@ Toujours Routage privé Autoriser la rétrogradation - Mode de routage des messages Jamais Serveurs inconnus Non Lorsque l\'IP est masquée Oui - Rabattement du routage des messages Afficher le statut du message Protection de l\'adresse IP Fichiers @@ -1803,7 +1801,6 @@ Protégez votre adresse IP des relais de messagerie choisis par vos contacts. \nActivez-le dans les paramètres *Réseau et serveurs*. Réinitialiser au thème de l\'utilisateur - Afficher la liste des conversations dans une nouvelle fenêtre Teinte du fond d\'écran Fond d\'écran info sur la file du serveur : %1$s @@ -1979,9 +1976,7 @@ Off Léger Paramètres - se connecter message - ouvrir Confirmer la suppression du contact ? Le contact sera supprimé — cette action est irréversible ! Supprimer sans notification @@ -2171,7 +2166,6 @@ Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> La conversation sera supprimée pour tous les membres — cette action est irréversible ! La conversation sera supprimée pour vous — cette action est irréversible ! - Les conditions seront acceptées pour les opérateurs activés après 30 jours. La connexion nécessite une renégociation du chiffrement. avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> Adresse ou lien unique ? @@ -2188,7 +2182,6 @@ Pas de serveurs pour recevoir des messages. Pas de message Pour les réseaux sociaux - Vérifier plus tard Serveurs prédéfinis Signaler autre : seuls les modérateurs du groupe le verront. Activer les journaux (logs) @@ -2216,7 +2209,6 @@ Le protocole du serveur a été modifié. Activez Flux dans les paramètres Réseau et serveurs pour une meilleure confidentialité des métadonnées. Ce message a été supprimé ou n\'a pas encore été reçu. - Appuyez sur Créer une adresse SimpleX dans le menu pour la créer ultérieurement. Partager publiquement votre adresse Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. Seuls les propriétaires peuvent modifier les préférences. @@ -2245,7 +2237,6 @@ Pas de serveurs pour envoyer des fichiers. Veuillez réduire la taille du message et envoyer le à nouveau. Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. - pour une meilleure protection des métadonnées. - Premier message non lu à l\'ouverture.\n- Sauter aux messages cités. Respect de la vie privée de vos clients. Le deuxième opérateur prédéfini de l\'application ! @@ -2260,7 +2251,6 @@ La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne. Serveur ajouté à l\'opérateur %s. L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. - Sélectionnez les opérateurs de réseau à utiliser. SimpleX Chat et Flux ont conclu un accord pour intégrer à l’application des serveurs opérés par Flux. Notes Ou à partager en privé @@ -2272,17 +2262,14 @@ Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts. Comment il contribue à la protection de la vie privée Motif du signalement ? - Le rapport sera archivé pour vous. Contenu inapproprié Nouveau serveur Vous pouvez configurer les serveurs via les paramètres. Pour envoyer Profil inapproprié - Site web Liste Signalement des membres Modifier l\'ordre - Mise à jour Les messages directs entre membres sont interdits dans cette discussion. Non Oui @@ -2310,9 +2297,7 @@ Navigation améliorée dans les discussions Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. Le message est trop volumineux ! - Mobiles à distance Modifier la liste - Vous pouvez définir les opérateurs dans les paramètres Réseau et serveurs. Transparence Vous pouvez définir un nom de connexion pour vous rappeler avec qui le lien a été partagé. Autoriser le signalement des messages aux modérateurs. @@ -2425,7 +2410,6 @@ Envoyer la demande Envoyer la demande sans message Envoyez vos commentaires privés aux groupes. - Envoyé à votre contact après la connexion. Le serveur requiert une autorisation pour se connecter au relais, vérifiez le mot de passe. Avertissement du serveur Partager l\'ancienne adresse @@ -2441,11 +2425,9 @@ L\'adresse sera courte et votre profil sera partagé via l\'adresse. Le lien sera court, et le profil de groupe sera partagé via le lien. L\'expéditeur n\'en sera PAS informé. - Ce réglage est pour votre profil actuel Propriétaires & contributeurs contributeur Description - Ajouter Ajouter une description Votre réseau vous @@ -2456,7 +2438,6 @@ Votre canal Lien de canal Lien de canal - Membres du canal Nom du canal Préférences du canal profil du canal mis à jour @@ -2504,7 +2485,6 @@ Vidéos Messages vocaux Liens - Filtre Depuis l\'historique Parler à quelqu\'un Permettez à quelqu\'un de se connecter à vous @@ -2561,11 +2541,6 @@ Délai d\'attente dépassé pour le routage privé L’empreinte de l’adresse du serveur de transfert ne correspond pas au certificat : %1$s. L’empreinte de l’adresse du serveur de destination ne correspond pas au certificat : %1$s. - Nom de canal non pris en charge - Nom de contact non pris en charge - La connexion via un nom de canal nécessite une version plus récente de l\'application. - La connexion via un nom de contact nécessite une version plus récente de l\'application. - Veuillez mettre à jour l\'application. Erreur de nom SimpleX Aucun de vos serveurs n\'est configuré pour résoudre les noms SimpleX. Configurez des serveurs ou utilisez un lien de connexion. Le serveur %1$s ne prend pas en charge la résolution de nom. Configurez les serveurs ou utilisez un lien de connexion. @@ -2605,11 +2580,9 @@ Le canal exigeait que ce message soit signé, mais la signature est manquante. Accepter la demande de contact Le membre a été supprimé : impossible d\'accepter la demande. - Mentions non lues Vérifier le nom Vérifier les noms SimpleX Nom SimpleX non vérifié - Nom SimpleX Votre nom SimpleX Définir un nom SimpleX Erreur lors de l\'enregistrement du nom @@ -2676,7 +2649,6 @@ L\'application a supprimé ce message après %1$d tentatives de réception. Supprimer le suivi des liens Demandes de contact dans les groupes - À propos Contact Soutenir le projet Aide et support @@ -2906,9 +2878,7 @@ Le canal commencera à fonctionner avec %1$d relais sur %2$d. Continuer ? Il s’agit d’une adresse de relais de messagerie, elle ne peut pas être utilisée pour se connecter. Ouvrir le canal - Ouvrir un nouveau canal %1$s !]]> - Erreur lors de l\'ouverture du canal Débloquer l\'abonné pour tout le monde ? Activer les aperçus de liens ? L’aperçu du lien sera demandé via le proxy SOCKS. La résolution DNS peut néanmoins toujours être effectuée localement via votre résolveur DNS. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 3493f56711..7be983c71a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -137,7 +137,6 @@ Odstraniti %d poruke? Odstraniti Podešavanje - Ažuriraj Odstraniti sve datoteke Odstraniti profil razgovora? Poruke @@ -196,7 +195,6 @@ Dodaj prijatelja Dodaj člana tima Arhiva - poveži Kamera Više Otključaj @@ -502,7 +500,6 @@ Vaši kontakti Ne Započeti razgovor? - otvoriti Nastaviti Otvoriti Pozadina @@ -865,7 +862,6 @@ autor Izvesti temu Žive poruke - Website dani Prosleđivanje %1$s poruka Kada je dostupno @@ -880,7 +876,6 @@ Nalepiti link koji ste primili Oceniti aplikaciju Nema nepročitanih razgovora - Izveštaj će biti arhiviran za Vas. Sačuvati neiskorišćenu pozivnicu? Glasovna poruka… Koristiti trenutni profil @@ -1110,7 +1105,6 @@ Unos pin koda Prosleđena poruka Ništa za prosleđivanje - Pregledati kasnije kontakt ima e2e šifrovanje Zaključati nakon Skriven kontakt: diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 504d68b1a0..82518712da 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1749,11 +1749,9 @@ Privát útválasztás Privát útválasztás használata az ismeretlen kiszolgálókhoz. Privát útválasztás használata minden esetben. - Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. - Üzenet-útválasztási tartalék Privát üzenet-útválasztás Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1778,7 +1776,6 @@ Csevegés témája Kitöltés Profiltéma - Csevegési lista megjelenítése új ablakban Világos Világos mód Fogadott válaszüzenet-buborék színe @@ -1952,7 +1949,6 @@ hívás A partner törölve lesz – ez a művelet nem vonható vissza! Csak a beszélgetés törlése - megnyitás Beszélgetés törölve! Partner törölve! Archivált partnerek @@ -1969,7 +1965,6 @@ üzenet Beszélgetés megtartása Biztosan törli a partnert? - kapcsolódás Könnyen elérhető eszköztárak Törlés értesítés nélkül Beállítások @@ -2098,7 +2093,6 @@ A kapcsolat elérte a kézbesítetlen üzenetek számának határát, a partnere lehet, hogy offline állapotban van. Nincs üzenet Ez az üzenet törölve lett vagy még nem érkezett meg. - Koppintson a SimpleX-cím létrehozása menüpontra a későbbi létrehozáshoz. Cím nyilvános megosztása SimpleX-cím megosztása a közösségi médiában. Egyszer használható meghívó megosztása egy baráttal @@ -2115,12 +2109,7 @@ Hálózatüzemeltetők Az alkalmazás úgy védi az adatait, hogy minden egyes beszélgetéshez más-más üzemeltetőt használ. Például, ha a partnere egy SimpleX Chat kiszolgálón keresztül fogadja az üzeneteket, akkor az Ön alkalmazása egy Flux kiszolgálón keresztül fogja azokat kézbesíteni. - Jelölje ki a használni kívánt hálózatüzemeltetőket. - Felülvizsgálat később A kiszolgálókat a „Hálózat és kiszolgálók” menüben konfigurálhatja. - A feltételek 30 nap elteltével lesznek elfogadva az engedélyezett üzemeltetők számára. - Az üzemeltetőket a „Hálózat és kiszolgálók” menüben konfigurálhatja. - Frissítés Folytatás Feltételek felülvizsgálata Elfogadott feltételek @@ -2130,7 +2119,6 @@ %s.]]> %s kiszolgáló Hálózatüzemeltető - Weboldal Feltételek elfogadásának ideje: %s. A feltételek el lesznek fogadva a következő időpontban: %s. Kiszolgálók használata @@ -2166,13 +2154,11 @@ A második előre beállított üzemeltető az alkalmazásban! A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Eszköztárak - a metaadatok jobb védelme érdekében. Továbbfejlesztett csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói Vagy archívumfájl importálása - Távoli hordozható eszközök Xiaomi eszközök: engedélyezze az automatikus indítást a rendszerbeállításokban, hogy az értesítések működjenek.]]> Másolhatja és csökkentheti az üzenet méretét a küldéshez. Adja hozzá a munkatársait a beszélgetésekhez. @@ -2253,7 +2239,6 @@ Elrendezés módosítása Hiba történt a jelentés létrehozásakor Hiba történt a beállítások mentésekor - A jelentés archiválva lesz az Ön számára. Tartalom jelentése: csak a csoport moderátorai látják. Archívum Archiválja a jelentést? @@ -2302,7 +2287,6 @@ A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata - Olvasatlan említések Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. @@ -2431,7 +2415,6 @@ Partneri kapcsolatkérés elutasítása A kérés küldője NEM lesz értesítve. Saját profil - Elküldés a partnernek a kapcsolódást követően. Üdvözlőüzenet Frissíti a címet? A cím rövid lesz és a profil meg lesz osztva a címen keresztül. @@ -2486,7 +2469,6 @@ Partneri kapcsolatkérések a csoportok tagjaitól A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért - Ez a beállítás a jelenlegi profiljára vonatkozik Megnyitás a bot használatához Koppintson a „Kapcsolódás” gombra a bot használatához Bot @@ -2516,7 +2498,6 @@ Eltávolítás és az üzeneteinek törlése Összes üzenet Fájlok - Szűrő Képek Hivatkozások Fájlok keresése @@ -2535,7 +2516,6 @@ Csatorna Csatorna Csatornahivatkozás - Csatornatagok Csatorna neve Kapcsolódás kapcsolódott @@ -2550,7 +2530,6 @@ új meghíva Csatorna megnyitása - Új csatorna megnyitása Tulajdonos Tulajdonosok és közreműködők Csatorna elhagyása @@ -2633,7 +2612,6 @@ Átjátszó címe Ez egy csevegési átjátszó címe, nem használható kapcsolódásra. %1$s nevű csatornához!]]> - Hiba történt a csatorna megnyitásakor Az összes feliratkozó számára feloldja a feliratkozó letiltását? Átjátszó tesztelése a nevének lekéréséhez.]]> Csatorna teljes neve: @@ -2773,7 +2751,6 @@ Felső sáv Az új %1$s nevű csatornája %3$d átjátszóból %2$d átjátszóhoz kapcsolódott.\nHa visszavonja, akkor a csatorna törlődni fog – de később újra létrehozhatja. Visszavonás és a csatorna törlése - Hozzáadás Átjátszó hozzáadása Átjátszók hozzáadása %d átjátszó kiválasztva @@ -2805,14 +2782,8 @@ az átjátszó üzemeltetője elutasította Az alkalmazás már fut Lehet, hogy egy másik alkalmazáspéldány fut, vagy nem zárult be megfelelően. Így is elindítja? - Nem támogatott csatornanév - Nem támogatott partnernév - A csatorna nevén keresztüli kapcsolódáshoz újabb alkalmazásverzió szükséges. - A partner nevén keresztüli kapcsolódáshoz újabb alkalmazásverzió szükséges. - Frissítse az alkalmazást. Alkalmazásfrissítés szükséges Ehhez a csoporthoz az alkalmazás újabb verziója szükséges. A csatlakozáshoz frissítse az alkalmazást. - Névjegy Kapcsolat A projekt támogatása Csevegési adatok @@ -2862,7 +2833,6 @@ Név ellenőrzése SimpleX-nevek ellenőrzése Nincs ellenőrizve a SimpleX-név - SimpleX-név Saját SimpleX-név SimpleX-név beállítása Hiba történt a név mentésekor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index b5c35bc7c6..ea18b5e428 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -545,7 +545,6 @@ Terlalu banyak video! Pesan suara pesan - buka Sistem setel alamat kontak baru Tema gelap @@ -969,8 +968,6 @@ Versi inti: v%s Ketika IP disembunyikan Warna antarmuka - Fallback perutean pesan - Mode routing pesan Routing pribadi JANGAN gunakan routing pribadi. Gunakan routing pribadi dengan server yang tak dikenal ketika alamat IP tidak dilindungi. @@ -1330,7 +1327,6 @@ Buat grup Nama lengkap grup: Simpan profil grup - Peramban Server Anda Gagal simpan profil grup %s server @@ -1389,7 +1385,6 @@ Operator server Operator jaringan Aplikasi ini melindungi privasi Anda dengan gunakan operator yang berbeda setiap percakapan. - Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. Alat pengembang Warna obrolan permintaan koneksi @@ -1416,7 +1411,6 @@ %1$s!]]> Batal pindah Potongan diunggah - hubungkan Percakapan dihapus! Pesan sementara Hapus catatan pribadi? @@ -1487,7 +1481,6 @@ Permintaan koneksi terkirim! Keamanan koneksi Nama telah benar untuk %s? - Anda dapat konfigurasi operator di pengaturan Jaringan dan server. Kesalahan basis data Frasa sandi basis data berbeda dengan yang disimpan di Keystore. Ubah peran grup? @@ -1589,14 +1582,11 @@ Tiada layanan latar belakang Aplikasi selalu berjalan di latar belakang Periksa pesan setiap 10 menit - Pilih operator jaringan yang akan digunakan. Misalnya, jika kontak Anda menerima pesan melalui server SimpleX Chat, aplikasi Anda akan mengirimkannya melalui server Flux. Bagaimana ini membantu privasi Lanjutkan Anda dapat konfigurasi server di pengaturan. repositori GitHub kami.]]> - Nilai nanti - Perbarui Rincian tautan unduhan untuk setiap profil obrolan yang Anda miliki di aplikasi.]]> untuk setiap kontak dan anggota grup.\nHarap diperhatikan: jika Anda memiliki banyak koneksi, konsumsi baterai dan lalu lintas dapat jauh lebih tinggi dan beberapa koneksi mungkin gagal.]]> @@ -1696,7 +1686,6 @@ negosiasi ulang enkripsi diperlukan negosiasi ulang enkripsi diizinkan untuk %s Aktifkan Flux di pengaturan Jaringan dan server untuk privasi metadata yang lebih baik. - untuk privasi metadata lebih baik. Navigasi obrolan ditingkatkan Galat Selesaikan migrasi pada perangkat lain. @@ -1907,7 +1896,6 @@ Anda dapat bagikan alamat sebagai tautan atau kode QR - siapa pun dapat terhubung dengan Anda. Simpan Jadikan profil pribadi! - Ponsel jarak jauh Jalankan obrolan Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. dihapus %1$s @@ -2197,14 +2185,12 @@ Gambar tidak dapat didekodekan. Silakan coba gambar lain atau hubungi pengembang. Kunci salah atau koneksi tidak dikenal - kemungkinan besar koneksi ini dihapus. Mulai obrolan baru - Ketuk Buat alamat SimpleX di menu untuk membuatnya nanti. Untuk terhubung, kontak Anda dapat pindai kode QR atau gunakan tautan di aplikasi. Profil Anda %1$s akan dibagikan. Untuk melindungi tautan Anda dari penggantian, Anda dapat membandingkan kode keamanan kontak. Pembaruan unduhan dibatalkan Profil Anda saat ini Anda menggunakan profil samaran untuk grup ini - untuk mencegah berbagi profil utama Anda, undang kontak tidak diizinkan - Lihat daftar obrolan di jendela baru Anda mengirim undangan grup Tautan ini bukan tautan koneksi yang valid! Anda akan berhenti menerima pesan dari grup ini. Riwayat obrolan akan disimpan. @@ -2277,7 +2263,6 @@ Laporkan profil anggota: hanya moderator grup yang akan melihat. Arsip laporan? Berkas diblokir oleh operator server:\n\n%1$s. - Laporan akan diarsipkan untuk Anda. moderator Laporkan konten: hanya moderator grup yang akan melihat. Ubah daftar @@ -2305,7 +2290,6 @@ 1 tahun bawaan (%s) Bisukan semua - Sebutan belum terbaca Port TCP untuk pesan Gunakan port TCP %1$s jika tidak ada port yang ditentukan. Gunakan port peramban @@ -2474,7 +2458,6 @@ Kirim permintaan Kirim permintaan tanpa pesan Kirimkan masukan pribadi Anda ke grup. - Dikirim ke kontak Anda setelah terhubung. Pasang bio profil dan pesan sambutan. Bagikan alamat lama Bagikan tautan lama @@ -2496,7 +2479,6 @@ Alamat akan singkat, dan profil Anda akan dibagikan melalui alamat tersebut. Tautan akan singkat, dan profil grup akan dibagikan melalui tautan tersebut. Pengirim TIDAK akan diberi tahu. - Pengaturan ini untuk profil Anda saat ini Waktu menghilang hanya diatur untuk kontak baru. Untuk mengirim perintah, Anda harus terhubung. Untuk menggunakan profil lain setelah mencoba hubungkan, hapus chat dan gunakan tautan lagi. @@ -2535,11 +2517,9 @@ %1$d pelanggan %1$d pelanggan %1$s mendukung SimpleX Chat. Lencana berakhir pada %2$s. - Tentang diterima daftar yang di akui aktif - Tambahkan Tambah relay Tambah relay Tambahkan relay untuk memulihkan pengiriman pesan. @@ -2572,7 +2552,6 @@ Gagal menyiarkan Saluran Tautan saluran - Anggota saluran Nama saluran Preferensi saluran Profil saluran disimpan di perangkat pelanggan dan di relai obrolan. @@ -2602,8 +2581,6 @@ Hubungkan terhubung menghubungkan - Menghubungkan melalui nama saluran memerlukan versi aplikasi yang lebih baru. - Menghubungkan melalui nama kontak memerlukan versi aplikasi yang lebih baru. Koneksi gagal Hubungkan melalui tautan atau kode QR Kontak @@ -2648,7 +2625,6 @@ Galat saat menambahkan relai Galat saat membuat saluran Gagal menghapus pesan - Galat saat membuka saluran galat: %s Galat saat menyimpan profil saluran Galat saat membagikan saluran @@ -2656,7 +2632,6 @@ gagal gagal Berkas - Filter Agar siapa pun dapat menghubungi Anda Dari riwayat (dari pemilik) @@ -2705,9 +2680,6 @@ Tidak ada relai Tidak ada relay yang dipilih Perekaman suara tidak didukung di platform Anda. - Nama saluran tidak didukung - Nama kontak tidak didukung - Silakan perbarui aplikasinya. Grup ini membutuhkan versi aplikasi yang lebih baru. Silakan perbarui aplikasi untuk bergabung. Cari gambar Cari video diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 16ba6db570..2f19777c2b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1772,12 +1772,10 @@ Mai Server sconosciuti Usa l\'instradamento privato con server sconosciuti. - Modalità instradamento messaggio Usa l\'instradamento privato con server sconosciuti quando l\'indirizzo IP non è protetto. Invia messaggi direttamente quando il tuo server o quello di destinazione non supporta l\'instradamento privato. Quando l\'IP è nascosto - Ripiego instradamento messaggio Mostra stato del messaggio Consenti downgrade Sempre @@ -1819,7 +1817,6 @@ Adatta Risposta inviata Imposta tema predefinito - Mostra la lista di chat in una nuova finestra Sistema Tinta dello sfondo Buon pomeriggio! @@ -1989,7 +1986,6 @@ Forte chiama messaggio - apri cerca Impostazioni Elimina senza avvisare @@ -2016,7 +2012,6 @@ Invia un messaggio per attivare le chiamate. video Puoi ancora vedere la conversazione con %1$s nell\'elenco delle chat. - connetti Contatto eliminato! Confermare l\'eliminazione del contatto? Chatta @@ -2134,12 +2129,8 @@ O per condividere in modo privato Operatori di rete L\'app protegge la tua privacy usando diversi operatori per ogni conversazione. - Puoi configurare gli operatori nelle impostazioni di rete e server. Operatori del server - Seleziona gli operatori di rete da usare. Continua - Aggiornamento - Leggi più tardi Server preimpostati Condizioni accettate Le condizioni verranno accettate automaticamente per gli operatori attivati il: %s. @@ -2151,7 +2142,6 @@ Operatore di rete Server di %s Usa %s - Sito web %s.]]> Condizioni accettate il: %s. Operatore @@ -2189,18 +2179,15 @@ Indirizzo o link una tantum? Impostazioni dell\'indirizzo con un solo contatto - condividilo di persona o tramite un messenger.]]> - Le condizioni verranno accettate per gli operatori attivati dopo 30 giorni. Le condizioni verranno accettate il: %s. Errore di accettazione delle condizioni Errore di salvataggio dei server - per una migliore privacy dei metadati. Ad esempio, se il tuo contatto riceve i messaggi tramite un server di SimpleX Chat, la tua app li consegnerà tramite un server di Flux. Navigazione della chat migliorata Nuovo server Usa per i file Indirizzo SimpleX o link una tantum? Questo messaggio è stato eliminato o non ancora ricevuto. - Tocca Crea indirizzo SimpleX nel menu per crearlo più tardi. La connessione ha raggiunto il limite di messaggi non consegnati, il contatto potrebbe essere offline. Usa i server Puoi configurare i server nelle impostazioni. @@ -2212,7 +2199,6 @@ Condividi indirizzo pubblicamente Condividi l\'indirizzo SimpleX sui social media. O importa un file dell\'archivio - Telefoni remoti I messaggi diretti tra i membri sono vietati in questa chat. Dispositivi Xiaomi: attiva l\'avvio automatico nelle impostazioni di sistema per fare funzionare le notifiche.]]> Aggiungi i membri del tuo team alle conversazioni. @@ -2303,7 +2289,6 @@ Spam Segnala Motivo della segnalazione? - La segnalazione verrà archiviata per te. Segnala profilo: solo i moderatori del gruppo lo vedranno. Segnala spam: solo i moderatori del gruppo lo vedranno. Segnala violazione: solo i moderatori del gruppo lo vedranno. @@ -2339,7 +2324,6 @@ Porta TCP per i messaggi Usa porta web Silenzia tutto - Menzioni non lette Puoi menzionare fino a %1$s membri per messaggio! I membri possono segnalare messaggi ai moderatori. Archiviare tutte le segnalazioni? @@ -2465,7 +2449,6 @@ Inviare una richiesta di contatto? Invia richiesta Invia richiesta senza messaggio - Inviato al tuo contatto dopo la connessione. Aggiornare il link del gruppo? Aggiorna Aggiornare l\'indirizzo? @@ -2521,7 +2504,6 @@ Richieste di contatto nei gruppi Il membro è eliminato - impossibile accettare la richiesta connessione richiesta dal gruppo %1$s - Questa impostazione è per il tuo profilo attuale Consenti file e contenuti multimediali solo se il tuo contatto li consente. Consenti ai tuoi contatti di inviare file e contenuti multimediali. Bot @@ -2560,7 +2542,6 @@ Cerca messaggi vocali Video Messaggi vocali - Filtro Connessione fallita fallito Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. @@ -2580,7 +2561,6 @@ Canale Canale Link del canale - Membri del canale Nome del canale Il canale verrà eliminato per tutti gli iscritti, non è reversibile! Il canale verrà eliminato per te, non è reversibile! @@ -2610,7 +2590,6 @@ Inserisci il nome del relay… Errore di aggiunta del relay Errore di creazione del canale - Errore di apertura del canale fallito fallito Ottieni link @@ -2624,7 +2603,6 @@ Nessun relay di chat attivato. Non tutti i relay sono connessi Apri canale - Apri il nuovo canale Proprietario Proprietari e collaboratori Indirizzo relay preimpostato @@ -2807,7 +2785,6 @@ L\'anteprima del link verrà richiesta via proxy SOCKS. La ricerca DNS può ancora accadere localmente tramite il tuo risolutore DNS. Barra superiore Il tuo nuovo canale %1$s è connesso a %2$d di %3$d relay.\nSe annulli, il canale verrà eliminato. Potrai crearlo di nuovo. - Aggiungi Aggiungi relay Aggiungi relay Annulla ed elimina il canale @@ -2844,7 +2821,6 @@ %1$d proprietari %1$d proprietari e collaboratori %1$s ha sostenuto SimpleX Chat. La targhetta è scaduta il %2$s. - Informazioni lista riconosciuta Aggiungi questo codice alla tua pagina web. Mostrerà l\'anteprima del tuo canale / gruppo. Opzioni avanzate @@ -2855,8 +2831,6 @@ La targhetta non può essere verificata Pagina web del canale Dati della chat - La connessione tramite nome del canale richiede una versione dell\'app più recente. - La connessione tramite nome del contatto richiede una versione dell\'app più recente. Contatto collaboratore Copia codice @@ -2868,7 +2842,6 @@ Verrà mostrato agli iscritti e usato per permettere il caricamento dell\'anteprima. Più privacy Solo la tua pagina soprastante può mostrare l\'anteprima. - Aggiorna l\'app. %s ha investito nella raccolta fondi di SimpleX Chat. %s sostiene SimpleX Chat. iscritto @@ -2877,8 +2850,6 @@ Il ruolo verrà cambiato in "%s". Verrà avvisato chiunque nel canale. Non è stato possibile verificare questa targhetta e potrebbe non essere autentica. Questo gruppo richiede una versione dell\'app più recente. Aggiorna l\'app per entrare. - Nome del canale non supportato - Nome del contatto non supportato Targhetta non verificata I relay di chat usati non supportano le pagine web. Codice pagina web @@ -2893,7 +2864,6 @@ Errore del risolutore: %1$s Il server %1$s non supporta la risoluzione dei nomi. Configura i server o usa un link di connessione. Imposta nome SimpleX - Nome SimpleX Errore del nome SimpleX Nome SimpleX non verificato Il nome SimpleX %1$s è registrato, ma non ha alcun link valido. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 8487614c9a..a2113482d9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1738,7 +1738,6 @@ אוזניות ערכת נושא לפרופיל צבעי הצא\'ט - הצג רשימת שיחות בחלון חדש מצב הקובץ סטטוס הודעה מצב הקובץ:%s @@ -2000,7 +1999,6 @@ אל תחמיץ הודעות חשובות. הורד גרסאות חדשות מ GitHub. פרופיל לא הולם - לפרטיות מטא דאטא טובה יותר. אפשר Flux בהגדרות רשת ושרתים בשביל לשפר את הפרטיות של המטא דאטא מעביר %1$s הודעות שגיאה בקריאת משפט-סיסמה של מסד נתונים @@ -2123,7 +2121,6 @@ צ\'אטים פרטיים, קבוצות ואנשי הקשר שלך אינם נגישים למפעילי השרת. לדוגמה, אם איש הקשר שלך מקבל הודעות דרך שרת SimpleX Chat, האפליקציה שלך תעביר אותן דרך שרת Flux. מדיניות פרטיות ותנאי שימוש. - ניתן להגדיר מפעילים בהגדרות רשת ושרתים. לא זנב לבועה סרגל כלים נגיש לצ\'אט diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index 1e0686d2de..c28136d201 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1821,7 +1821,6 @@ \n*ネットワークとサーバー*設定から有効にして下さい。 いいえ はい - メッセージルーティングモード フォントサイズ ベータ アップデートを確認 @@ -1847,7 +1846,6 @@ 全てのプロフィール 通話 連絡先の削除を確認しますか? - 接続 連絡先が削除されます - この操作は取り消せません! プロフィールの切り替えエラー メディアのぼかし @@ -1965,7 +1963,6 @@ 情報がありません、リロードしてください SMPサーバ メッセージ - ウェブサイト ビデオ 設定画面からサーバを構成できます。 改定履歴を開く @@ -1995,7 +1992,6 @@ バックグラウンドサービスを使用しません 利用条件の承諾 %s の利用条件に承諾しています。]]> - 後で作成する場合はメニューから「SimpleXのアドレスを作成」を選択してください。 運営者について 1年 1 件のレポート @@ -2053,7 +2049,6 @@ リンク すべて 音声メッセージ - フィルター メンバーとして承認する オブザーバーとして承認する スパム @@ -2097,11 +2092,6 @@ Tor または VPN を使用しない場合、あなたのIPアドレスはこれらのXFTPリレーに表示されます:\n%1$s。 サポートされていない接続リンク このリンクには新しいバージョンのアプリが必要です。アプリをアップグレードするか、互換性のあるリンクを送るよう連絡先に依頼してください。 - サポートされていないチャンネル名 - サポートされていない連絡先名 - チャンネル名による接続には新しいバージョンのアプリが必要です。 - 連絡先名による接続には新しいバージョンのアプリが必要です。 - アプリをアップグレードしてください。 チャンネルは一時的に利用できません チャンネルにアクティブなリレーがありません。後でもう一度参加をお試しください。 アプリの更新が必要です @@ -2126,7 +2116,6 @@ 報告をアーカイブしますか? %d件の報告をアーカイブしますか? すべての報告をアーカイブしますか? - 報告はあなたのためにアーカイブされます。 すべての報告があなたのためにアーカイブされます。 自分のみ すべてのモデレーター向け @@ -2265,7 +2254,6 @@ この操作は元に戻せません - このチャットで選択した時点より前に送受信したメッセージは削除されます。 メッセージ削除を無効にする デバイスからチャットメッセージを削除します。 - 開く 検索 会話を保持 会話のみ削除 @@ -2281,7 +2269,6 @@ 送信者には通知されません。 メンバーが削除されています - リクエストを承諾できません すべてミュート - 未読のメンション リストを変更 リスト名と絵文字はすべてのリストで異なる必要があります。 順序を変更 @@ -2321,7 +2308,6 @@ IPが隠されている場合 あなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 IPアドレスが保護されており、かつあなたまたは宛先のサーバがプライベートルーティングをサポートしていない場合、メッセージを直接送信します。 - メッセージルーティングのフォールバック IPアドレスを保護するため、プライベートルーティングはあなたのSMPサーバを使用してメッセージを配信します。 メッセージング用のTCPポート Webポートを使用 @@ -2342,7 +2328,6 @@ 新しいリリースの通知を受け取るには、安定版またはベータ版の定期チェックをオンにしてください。 非推奨のオプション 設定の保存エラー - 接続後に連絡先に送信されます。 ウェルカムメッセージ またはプライベートに共有する場合 SimpleXアドレスまたはワンタイムリンク? @@ -2389,12 +2374,7 @@ アプリは会話ごとに異なる運営者を使用することで、あなたのプライバシーを保護します。 複数の運営者が有効な場合、どの運営者も誰が誰と通信しているかを知るためのメタデータを持ちません。 例えば、あなたの連絡先がSimpleX Chatのサーバ経由でメッセージを受信する場合、あなたのアプリはFluxのサーバ経由でそれらを配信します。 - 使用するネットワーク運営者を選択してください。 プライバシーにどう役立つか - 有効な運営者の条件は30日後に承諾されます。 - 運営者は「ネットワークとサーバ」設定で構成できます。 - 後で確認 - 更新 あなたのネットワーク ネットワークのルーターは\n誰が誰と話しているかを知ることができません ルーターを設定 @@ -2411,7 +2391,6 @@ アプリは不明なファイルサーバからのダウンロードの確認を求めます(.onion の場合、またはSOCKSプロキシが有効な場合を除く)。 Tor または VPN を使用しない場合、あなたのIPアドレスはファイルサーバに表示されます。 リンクのトラッキングを削除 - この設定は現在のプロフィールに適用されます チャット一覧からリンクを開く はい いいえ @@ -2421,7 +2400,6 @@ 完全なリンクを開く クリーンなリンクを開く グループからの連絡先リクエスト - リモートのモバイル端末 デフォルト(%s) エクスポートしたアーカイブを保存できます。 エクスポートしたデータベースを移行できます。 @@ -2433,7 +2411,6 @@ 手の届くチャットツールバー 下部のバー 上部のバー - 新しいウィンドウでチャット一覧を表示 チャンネルに参加または作成した場合、それらは恒久的に機能しなくなります。 チャンネルから退出しますか? チャットから退出しますか? @@ -2475,7 +2452,6 @@ 設定を変更できるのはチャットのオーナーだけです。 リンク 管理者とのチャット - チャンネルのメンバー チャットリレー 購読者を削除しますか? メンバーを削除しますか? @@ -2616,7 +2592,6 @@ ネットワークの分散化 アプリに2番目のプリセット運営者が登場! メタデータのプライバシー向上のため、「ネットワークとサーバ」設定でFluxを有効にしてください。 - メタデータのプライバシー向上のため。 チャットナビゲーションの改善 - 最初の未読メッセージでチャットを開きます。\n- 引用されたメッセージにジャンプします。 ビジネスチャット @@ -2801,7 +2776,6 @@ あなたのプロフィール %1$s はチャンネルのリレーと購読者に共有されます。\nリレーはチャンネルのメッセージにアクセスできます。 リレーを構成 失敗 - 追加 リレーを追加 リレーを追加 利用可能なリレーがありません @@ -2817,10 +2791,8 @@ リレーアドレス これはチャットリレーのアドレスであり、接続には使用できません。 チャンネルを開く - 新しいチャンネルを開く あなたのチャンネル %1$s 用のあなたのリンクです!]]> - チャンネルを開く際のエラー 全員に対して購読者のブロックを解除しますか? リンクプレビューを有効にしますか? リンクプレビューを送信すると、あなたのIPアドレスがWebサイトに知られる可能性があります。これは後でプライバシー設定で変更できます。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index a987a0c92e..a4fd13d137 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -1080,7 +1080,6 @@ 업로드 확인 모바일에 연결 연결된 모바일 - 연결 그룹 멤버에게 메시지를 보낼 수 없음 전송 알림을 활성화하시겠습니까? 암호화 OK @@ -1379,7 +1378,6 @@ 성공적으로 설치됨 업데이트 설치 인터페이스 색상 - 메시지 라우팅 대체 그룹 멤버가 메시지 반응을 추가할 수 있습니다. 메시지 모양 메시지 대기열 정보 @@ -1395,7 +1393,6 @@ 향상된 서버 구성 메시지 메시지 서버 - 메시지 라우팅 모드 안녕하세요!\nSimpleX Chat 초대장이 도착했습니다: %s 내부 오류 잘못된 이름입니다! @@ -1471,7 +1468,6 @@ Flux 활성화 종단 간 암호화로 전송됩니다.]]> 앱이 항상 백그라운드에서 실행 - 활성화된 운영자에 대한 약관은 30일 후에 수락됩니다. 팀 멤버 추가하기 친구 추가 모든 멤버에게서 채팅이 삭제됩니다 - 이 결정은 되돌릴 수 없습니다! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml index 09c428e48b..c429a8341a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -108,7 +108,6 @@ Bêdengkirinê betal bike Bike favorît Ji favorîtan derxe - Behsên nexwendî Lîste çêke Li lîstê zêde bike Lîstê biguhere @@ -345,7 +344,6 @@ Sîstem Moda sîstemê Terî/Dûvik - Pêl Adresa SimpleXê çêke di meniwê de ji bo ko tu dûvre çêkî. Pêl Bikeve komê bike Bikeviyê Bikeve komê @@ -603,7 +601,6 @@ Etherneta bi qeblo WiFi Çi yî nû heye - Websîte Serverên WebRTC ICEyê Hişyarî: hinek dataya te kare winda bibe! dixwaze bi te re bikeve danûstandinê! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index a6882871c7..e09aef2162 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -539,7 +539,6 @@ Tiek izveidots tukšs čata profils Ja ievadīsiet paroli, dati tiks izdzēsti Uzstādīt paroli - Šis iestatījums ir paredzēts jūsu pašreizējam profilam Saņemts grupas notikums: 1 dalībnieks pievienojies Saņemts grupas notikums: 2 dalībnieki pievienojušies Saņemts grupas notikums: 3 dalībnieki pievienojušies @@ -1022,7 +1021,6 @@ Atvērt sarunu profilus Ziņojumu arhīvs %dth Ziņojumu arhīvs visi - Ziņojumu arhīvs Ziņojumu arhīvs apraksts visi Ziņojumu arhīvs man Ziņojumu arhīvs visiem moderātoriem @@ -1134,7 +1132,6 @@ Kontaktam jāpieņem Savienoties ar kontaktu %s? Meklējiet vai ielīmējiet simplex saiti - Adreses izveides instrukcija Nav izvēlēta saruna Izvēlēto sarunu vienības nav izvēlētas Izvēlēto sarunu vienības izvēlētas %d @@ -1167,8 +1164,6 @@ Mainīt automātisko sarunu dzēšanu Atspējot automātisko dzēšanu Sarunu laika ierobežojuma opciju apakšdaļa - Skatīt savienojumu - Skatīt atvērt Skatīt Skatīt zvanu Skatīt meklēšanu @@ -1273,7 +1268,6 @@ Atjaunot sarunu Atzīmēt kā iecienītu Noņemt no iecienītajiem - Neizlasītie minējumi Izveidot sarakstu Pievienot sarakstam Mainīt sarakstu @@ -1390,7 +1384,6 @@ Tīkla smp proxy režīms nezināms Tīkla smp proxy režīms neaizsargāts Tīkla smp proxy režīms nekad - Atjaunināt tīkla smp proxy režīmu? Tīkla smp proxy rezerves atļaut samazināšanu Tīkla smp proxy rezerves atļaut Tīkla smp proxy rezerves atļaut aizsargātu @@ -1398,7 +1391,6 @@ Tīkla smp proxy rezerves atļaut Tīkla smp proxy rezerves atļaut aizsargātu Tīkla smp proxy rezerves aizliegt - Atjaunināt tīkla smp proxy rezerves? Privātā maršrutēšana rādīt Privātā maršrutēšana skaidrojums Tīkla smp tīmekļa ports @@ -1461,7 +1453,6 @@ Pārtraukt adreses kopīgošanu Pārtraukt kopīgošanu Automātiski pieņemt kontaktus - Nosūtīts jūsu kontaktam pēc savienojuma izveides Sveiciena ziņa Ievadiet sveiciena ziņu (pēc izvēles) Saglabāt iestatījumus? @@ -1598,13 +1589,8 @@ Ievada tīkla operatori nevar redzēt, kas ar ko runā Ievada tīkla operatori lietotne izmantos maršrutēšanai Ievada tīkls par operatoriem - Ievada izvēlēties tīkla operatorus, ko izmantot Kā tas palīdz privātumam Ievada tīkla operatori konfigurēt caur iestatījumiem - Ievada tīkla operatori nosacījumi tiks pieņemti - Ievada tīkla operatori nosacījumi, ko jūs varat konfigurēt - Ievada tīkla operatori pārskatīt vēlāk - Ievada tīkla operatori atjaunināt Ievada tīkla operatori turpināt Ienākošais video zvans Ienākošais audio zvans @@ -1675,7 +1661,6 @@ Izmantot no darbvirsmas Jūsu sarunu datubāze Palaist sarunu - Attālie hosti Saruna notiek Saruna ir apstājusies Sarunu datubāze @@ -1799,7 +1784,6 @@ Sarunu apakšējā josla Vienas rokas saskarnes maiņas instrukcija Terminālis vienmēr redzams - Sarunu saraksts vienmēr redzams Nederīga migrācijas apstiprināšana Jaunināt un atvērt sarunu Samazināt un atvērt sarunu @@ -1994,7 +1978,6 @@ Operators Operatora serveri Operators - Operatora mājaslapa Operatora nosacījumi pieņemti Operatora nosacījumi tiks pieņemti Operatora izmantošanas slēdzis @@ -2317,7 +2300,6 @@ Tīkla decentralizācija Decentralizējiet tīklu, lai uzlabotu drošību. Tīkla decentralizācija, iespējot plūsmu - Tīkla decentralizācija, iespējot plūsmu iemesls Uzlabota sarunu navigācija Vieglāka navigācija sarunās. Uzņēmumu sarunas diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 2ec65534a4..c2cc76fa50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1772,7 +1772,6 @@ Gebruik altijd privéroutering. Gebruik privéroutering met onbekende servers. Gebruik GEEN privéroutering. - Berichtrouteringsmodus Gebruik privéroutering met onbekende servers wanneer het IP-adres niet beveiligd is. Downgraden toestaan Wanneer IP verborgen is @@ -1782,7 +1781,6 @@ Toon berichtstatus Om uw IP-adres te beschermen, gebruikt privéroutering uw SMP-servers om berichten te bezorgen. Stuur GEEN berichten rechtstreeks, zelfs als uw of de bestemmingsserver geen privéroutering ondersteunt. - Terugval op berichtroutering Privébericht routing Stuur berichten rechtstreeks als het IP-adres beschermd is en uw of bestemmingsserver geen privéroutering ondersteunt. Onbekende servers! @@ -1835,7 +1833,6 @@ Systeem Wallpaper achtergrond Stel het standaard thema in - Toon chat-lijst in nieuw venster geen Foutopsporing bezorging Informatie over berichtenwachtrij @@ -1999,13 +1996,11 @@ Bericht Berichten worden gemarkeerd voor verwijdering. De ontvanger(s) kunnen deze berichten onthullen. Niets geselecteerd - verbinden Contact verwijderd! Het contact wordt verwijderd. Dit kan niet ongedaan worden gemaakt! Behoud het gesprek bericht Alleen conversatie verwijderen - open Plak de link Uitnodiging Bereikbare app-toolbars @@ -2129,7 +2124,6 @@ Niet afgeleverde berichten De verbinding heeft de limiet van niet-afgeleverde berichten bereikt. Uw contactpersoon is mogelijk offline. Dit bericht is verwijderd of nog niet ontvangen. - Tik op SimpleX-adres maken in het menu om het later te maken. Adres openbaar delen Deel eenmalig een link met een vriend Deel het SimpleX-adres op sociale media. @@ -2143,13 +2137,8 @@ Adres instellingen Eenmalige link maken Serverbeheerders - Voor ingeschakelde operators worden de voorwaarden na 30 dagen geaccepteerd. Netwerkbeheerders - Later beoordelen - Selecteer welke netwerkoperators u wilt gebruiken. - Update De app beschermt uw privacy door in elk gesprek verschillende operators te gebruiken. - U kunt operators configureren in Netwerk- en serverinstellingen. Doorgaan Voorwaarden bekijken Uw servers @@ -2164,7 +2153,6 @@ %s servers Gebruik %s Gebruik servers - Website %s.]]> %s.]]> %s te gebruiken, moet u de gebruiksvoorwaarden accepteren.]]> @@ -2180,7 +2168,6 @@ Operatorserver Server toegevoegd aan operator %s. Transparantie - voor betere privacy van metagegevens. Verbeterde chatnavigatie Netwerk decentralisatie De tweede vooraf ingestelde operator in de app! @@ -2208,7 +2195,6 @@ U kunt servers configureren via instellingen. Of importeer archiefbestand Directe berichten tussen leden zijn in deze chat niet toegestaan. - Externe mobiele telefoons Xiaomi-apparaten: schakel Automatisch starten in de systeeminstellingen in om meldingen te laten werken.]]> Bericht is te groot! Verklein het bericht en verstuur het opnieuw. @@ -2299,7 +2285,6 @@ Rapport archiveren? rapporteren Reden melding? - Het rapport wordt voor u gearchiveerd. Anders melden: alleen groepsmoderators kunnen het zien. Spam melden: alleen groepsmoderators kunnen het zien. Rapporteer overtreding: alleen groepsmoderators kunnen dit zien. @@ -2334,7 +2319,6 @@ Gebruik een webpoort Gebruik TCP-poort %1$s wanneer er geen poort is opgegeven. Alles dempen - Ongelezen vermeldingen Je kunt maximaal %1$s leden per bericht vermelden! Hiermee kunt u berichten rapporteren aan moderators. Alle rapporten worden voor u gearchiveerd. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index bf47cebdb5..6b6c3ab3eb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1771,7 +1771,6 @@ Nigdy Niezabezpieczony NIE używaj prywatnego trasowania. - Tryb trasowania wiadomości Tak Nie Gdy IP ukryty @@ -1781,7 +1780,6 @@ Aby chronić Twój adres IP, prywatne trasowanie używa Twoich serwerów SMP, aby dostarczyć wiadomości. Nieznane serwery Używaj prywatnego trasowania z nieznanymi serwerami. - Rezerwowe trasowania wiadomości Prywatne trasowanie Wysyłaj wiadomości bezpośrednio, gdy adres IP jest chroniony i Twój lub docelowy serwer nie obsługuje prywatnego trasowania. Wysyłaj wiadomości bezpośrednio, gdy Twój lub docelowy serwer nie obsługuje prywatnego trasowania. @@ -1794,7 +1792,6 @@ Bez Tor lub VPN, Twój adres IP będzie widoczny do serwerów plików. Pliki Motyw profilu - Pokaż listę czatów w nowym oknie Kolory ciemnego trybu Jasny Otrzymano odpowiedź @@ -1981,7 +1978,6 @@ Zwiększ rozmiar czcionki. wiadomość Wiadomość - otwórz szukaj Zaznaczono %d wideo @@ -2002,7 +1998,6 @@ Odtwórz z listy czatów. Zarchiwizowane kontakty zadzwoń - połącz Stan połączenia i serwerów. Utwórz Nieprawidłowy link @@ -2183,7 +2178,6 @@ Czat zostanie usunięty dla wszystkich członków - nie można tego cofnąć! Warunki będą akceptowane w dniu: %s. Bezpieczeństwo połączenia - Warunki będą akceptowane dla operatorów włączonych po 30 dniach. Zmień kolejność Połączenie wymaga renegocjacji szyfrowania. przyjął %1$s @@ -2288,14 +2282,12 @@ Plik jest zablokowany przez operatora serwera:\n%1$s. Pliki Pliki i media są zabronione na tym czacie. - Filtr Odcisk palca w docelowym serwerze nie pasuje do certyfikatu: %1$s. Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. Napraw Naprawić połączenie? Dla wszystkich moderatorów - Lepsza prywatność metadanych. Dla profilu czatu %s: Na przykład, jeśli kontakt otrzyma wiadomości za pośrednictwem serwera czatu SimpleX, aplikacja dostarczy je za pośrednictwem serwera Flux. Dla mnie @@ -2417,7 +2409,6 @@ odrzucono odrzucono Odrzucić członka? - Zdalne telefony komórkowe Usuń i skasuj wiadomości usunięty z grupy Usuń śledzenie linków @@ -2442,7 +2433,6 @@ Przejrzyj warunki sprawdzone przez administratorów Przejrzyj członków grupy - Przejrzyj później Przejrzyj członków Przejrzyj członków przed przyjęciem (pukanie). Zapisać ustawienia wstępu? @@ -2452,13 +2442,11 @@ Szukaj linków Szukaj wideo Szukaj wiadomości głosowych - Wybierz operatora sieci Wysłać prośbę o kontakt? Wyślij prywatne zgłoszenia Wyślij prośbę Wyślij prośbę bez wiadomości Wyślij swoją prywatną opinię do grup. - Wysłano do Twojego kontaktu po połączeniu. Serwer dodany do operatora %s. Operator serwera został zmieniony. Operatorzy serwera @@ -2484,7 +2472,6 @@ Dotknij Połącz aby rozpocząć czat Dotknij Połącz, aby wysłać prośbę Dotknij Połącz aby użyć bota - Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. Dotknij Dołącz do grupy Przekroczono limit czasu połączenia TCP Port TCP dla wiadomości @@ -2492,7 +2479,6 @@ Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. - Raport zostanie dla Ciebie zarchiwizowany. Rola zostanie zmieniona na %s. Wszyscy uczestnicy czatu zostaną powiadomieni. Drugi predefiniowany operator w aplikacji! Nadawca NIE zostanie poinformowany. @@ -2500,7 +2486,6 @@ Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. Ta wiadomość została usunięta lub jeszcze nie otrzymana. - To ustawienie jest dla Twojego obecnego profilu. Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. Żeby odebrać @@ -2510,9 +2495,7 @@ Przeźroczystość Odblokować członków dla wszystkich? Niedostarczone wiadomości - Nieprzeczytane wzmianki Nieobsługiwane łącze połączenia - Aktualizacja Warunki aktualizacji Aktualizuj swój adres Upgrade @@ -2532,14 +2515,12 @@ Zobacz warunki Zobacz zaktualizowane warunki Wiadomości głosowe - Strona Internetowa Wiadomość powitalna Powitaj swoje kontakty Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. Tak zaakceptowałeś tego członka Nie masz połączenia z serwerem używanym do odbierania wiadomości z tego połączenia (brak subskrypcji). - Możesz skonfigurować operatorów w ustawieniach sieci i serwerów. Serwery można skonfigurować w ustawieniach. Możesz skopiować i zmniejszyć rozmiar wiadomości, aby ją wysłać. Możesz wzmiankować do %1$s członków na wiadomość! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 7e807ff9b3..5b54d0c452 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1648,7 +1648,6 @@ Desfocar mídia Borrar para melhor privacidade. Confirmar exclusão do contato? - conectar Apagar sem notificar Destaque adicional 2 Confirmar configurações de rede @@ -1731,7 +1730,6 @@ Pular essa versão Baixar %s (%s) Download da atualização cancelado - Mostrar lista de chats em nova janela Tamanho da fonte Fundo do papel de parede Tonalidade do papel de parede @@ -1846,7 +1844,6 @@ Servidores desconhecidos Desprotegido Nunca - Modo de roteamento de mensagens Conceder permissões Alto falante Headphones @@ -1912,7 +1909,6 @@ Proteja seu endereço IP dos relays de mensagens escolhidos pelos seus contatos.\nAtive nas configurações de Rede e servidores. Iniciar o chat Configurações - abrir Manter conversa Apenas apagar conversa Outros servidores XFTP @@ -2019,7 +2015,6 @@ Seus contatos NÃO envie mensagens diretamente, mesmo que o seu servidor ou o servidor de destino não suporte roteamento privado. Enviar mensagens diretamente quando o seu servidor ou o servidor de destino não for compatível com roteamento privado. - Retorno de roteamento de mensagens Mostrar status da mensagem Migrar de outro dispositivo Status da mensagem: %s @@ -2170,7 +2165,6 @@ Condições de uso Erro ao adicionar servidor Chats de empresas - para melhoria da privacidade de metadados. Exclusão mais rápida de grupos. Erro ao criar lista de chat Erro ao carregar lista de chats @@ -2194,7 +2188,6 @@ Renegociação de criptografia em andamento. Apagar Clique no botão de informação perto do campo de endereço para permitir usar o microfone. - As condições serão aceitas para operadores habilitados após 30 dias. Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux. A conexão não está pronta. Erros nas configurações de servidores. @@ -2284,7 +2277,6 @@ Proibir a denúncia de mensagens aos moderadores. Denunciar conteúdo: somente os moderadores do grupo poderão ver. Denunciar perfil de membro: somente moderadores do grupo poderão ver. - Dispositivos móveis remotos Remover membros? Denunciar outro: somente os moderadores do grupo poderão ver. Qual é a razão da denúncia? @@ -2308,7 +2300,6 @@ Endereço SimpleX ou link único? Som silenciado Alterne entre áudio e vídeo durante a chamada. - Selecione os operadores de rede que deseja utilizar. Revisar condições Endereços SimpleX e links únicos são seguros para compartilhar por meio de qualquer mensageiro. Operadores do servidor @@ -2317,7 +2308,6 @@ Denunciar spam: somente os moderadores do grupo poderão ver. Protocolo do servidor alterado. Compartilhe o endereço do SimpleX nas redes sociais. - Revisar depois Servidor O SimpleX Chat e o Flux fizeram um acordo para incluir servidores operados pelo Flux no aplicativo. conexão solicitada @@ -2330,13 +2320,10 @@ O segundo operador predefinido no aplicativo! Esta mensagem foi apagada ou ainda não foi recebida. Ver condições atualizadas - Toque em Criar endereço SimpleX no menu para criá-lo mais tarde. Usar porta TCP %1$s quando nenhuma porta for especificada. - A denúncia será arquivada para você. Para receber Esta ação não pode ser desfeita - as mensagens enviadas e recebidas nesta conversa antes do período selecionado serão apagadas. Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança com os dos seus contatos. - Menções não lidas Porta TCP para mensagens Usar porta web O aplicativo protege sua privacidade usando diferentes operadores em cada conversa. @@ -2349,12 +2336,9 @@ É possível ajustar os servidores através das configurações. Usar %s Usar servidores - Website Você pode mencionar até %1$s membros por mensagem! Final Você pode copiar e reduzir o tamanho da mensagem para enviá-la. - Atualização - Você pode configurar operadores em Configurações de rede & servidores. Usar para arquivos Você deixará de receber mensagens deste chat. O histórico do chat será preservado. Para realizar chamadas, permita o uso do microfone. Encerre a chamada atual e tente novamente. @@ -2395,13 +2379,11 @@ %1$d inscritos %1$s apoiou o SimpleX Chat. O selo expirou em %2$s. Você pode apoiar o SimpleX a partir da versão 7 do aplicativo. - Sobre Aceitar solicitação de contato Aceitar solicitação de contato aceito lista reconhecida ativo - Adicionar Adicionar colaboradores. Adicionar descrição Adicionar mensagem @@ -2483,7 +2465,6 @@ Nome completo do canal: O canal não possui relays ativos. Tente entrar novamente mais tarde. Link do canal - Membros do canal Nome do canal Preferências do canal perfil do canal atualizado @@ -2517,8 +2498,6 @@ conectado Conecte-se mais rápido! 🚀 conectando - Para se conectar usando o nome do canal, é necessário estar na versão mais recente do aplicativo. - Para se conectar usando o nome do contato, é necessário estar na versão mais recente do aplicativo. A conexão falhou Conectar-se a %s Contato @@ -2582,7 +2561,6 @@ Erro ao apagar conversa Erro ao apagar mensagem Erro ao marcar como lida - Erro ao abrir canal Erro ao abrir conversa Erro ao abrir grupo Erro ao recusar solicitação de contato @@ -2598,7 +2576,6 @@ Arquivos e mídias não são permitidos neste chat. Servidores de arquivos Servidores de arquivos: %s - Filtro A impressão digital no endereço do servidor de destino não corresponde ao certificado: %1$s. A impressão digital do endereço do servidor de encaminhamento não corresponde à do certificado: %1$s. A impressão digital do endereço do servidor não corresponde à do certificado: %1$s. @@ -2696,7 +2673,6 @@ Abrir link limpo Abrir link externo? Abrir link completo - Abrir novo canal Abrir nova conversa Abrir novo grupo Abrir para aceitar @@ -2708,7 +2684,6 @@ Proprietários e colaboradores Autonomia: você pode executar seus próprios relays. aguardando revisão - Atualize o aplicativo. Aguarde os moderadores do grupo analisarem seu pedido para participar. Endereço do relay predefinido Nome do relay predefinido @@ -2780,7 +2755,6 @@ Enviar solicitação Enviar solicitação sem mensagem Envie o link por qualquer aplicativo de mensagens - é seguro. Peça para colar no SimpleX. - Será enviado ao seu contato após a conexão. O servidor %1$s não suporta resolução de nomes. Configure os servidores ou use um link de conexão. O servidor requer autorização para se conectar ao relay; verifique a senha. Aviso do servidor @@ -2805,7 +2779,6 @@ Assinar mensagens SimpleX Link do canal SimpleX - Nome SimpleX Erro no nome SimpleX Nome SimpleX não verificado Nomes públicos SimpleX (BETA) @@ -2843,8 +2816,6 @@ Vídeos Seu dispositivo não tem suporte para gravação de áudio Link de conexão não suportado - Nome de canal não suportado - Nome de contato não suportado Nome não confirmado Seu endereço público O nome SimpleX %1$s está registrado, mas não está associado ao perfil. Se você for o proprietário, adicione-o ao perfil do endereço ou canal. @@ -2883,7 +2854,6 @@ Você nasceu sem uma conta. Sua rede O aplicativo removeu esta mensagem após %1$d tentativas de recebê-la. - Esta configuração se aplica ao seu perfil atual perfil do canal atualizado você aceitou este membro Código da página da web diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index f2437af22b..3af5fbee19 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -250,7 +250,6 @@ Răspuns trimis Partajează media… Partajează mesaj… - Afișează lista de conversații într-o fereastră nouă Afișează consola în fereastră nouă Configurează parola bazei de date Setează parola bazei de date @@ -677,7 +676,6 @@ Servere XFTP configurate Baza de date este criptată folosind o parolă aleatorie. Trebuie schimbată înainte de exportare. apel - conectare Contact șters! Conversație ștearsă! Actualizarea aplicației este descărcată @@ -914,7 +912,6 @@ Nicio conversație necitită Nicio conversație în lista %s. Nimic selectat - deschis Ajutor Doar contactul tău poate trimite mesaje care dispar. Se importă arhiva @@ -1035,7 +1032,6 @@ Nicio conversație filtrată Server nou Asigurați -vă că adresele Serverului WebRTC ICE sunt în format corect, separate pe linii și nu sunt duplicate. - Rutarea mesajelor Editează imaginea Deschide setările Acordare în setări @@ -1217,7 +1213,6 @@ Interfață de utilizator maghiară și turcă Redirecționează și salvează mesajele Activează Flux în setările Rețea și servere pentru o mai bună confidențialitate a metadatelor. - pentru o mai bună confidențialitate a metadatelor. Redirecționați până la 20 de mesaje simultan. Nu rata mesajele importante. Organizează conversațiile în liste @@ -1587,7 +1582,6 @@ Erori în configurarea serverelor. Adresa serverului de redirecționare este incompatibilă cu setările rețelei: %1$s. Nu există servere pentru a trimite fișiere. - Mod de rutare a mesajelor Niciodată Întoarce camera Link nevalid @@ -1751,7 +1745,6 @@ Raport Adresa de primire va fi schimbată la un alt server. Schimbarea adresei se va finaliza după ce expeditorul se conectează online. Salvezi setările de acces? - Revizuiți mai târziu Trimiterea confirmărilor este dezactivată pentru %d grupuri recenzie Operatorul serverului s-a schimbat. @@ -1803,8 +1796,6 @@ Trimite confirmări de livrare la Parola de autodistrugere a fost schimbată! Înregistrare actualizată la - Selectează operatorii de rețea de utilizat. - Telefoane mobile la distanță Trimite confirmări de citire Setați 1 zi Parolă de autodistrugere @@ -1965,8 +1956,6 @@ Atinge pentru a lipi linkul Textul pe care l-ai lipit nu este un link SimpleX. Stabil - Puteți configura operatorii în setările Rețea și servere. - Actualizare apel video (necriptat e2e) ID-ul următorului mesaj este incorect (mai mic sau egal cu precedentul).\nSe poate întâmpla din cauza unei erori sau când conexiunea este compromisă. Fără Tor sau VPN, adresa ta IP va fi vizibilă pentru serverele de fișiere. @@ -2016,7 +2005,6 @@ Rolul va fi schimbat în %s. Toți participanții la chat vor fi notificați. Rolul va fi schimbat în %s. Toți membrii grupului vor fi notificați. Folosește %s - Site web %s Servere Actualizați setările de rețea? Atingeți pentru a activa profilul. @@ -2155,8 +2143,6 @@ Blocarea SimpleX nu este activă! Autentificare sistem Baza de date nu funcționează corect. Atingeți pentru a afla mai multe. - Raportul va fi arhivat. - Atingeți Creează o adresă SimpleX în meniu pentru a o crea mai târziu. Acest text este disponibil în setări Ești invitat în grup Se așteaptă imaginea @@ -2164,7 +2150,6 @@ Se așteaptă videoclipul Eroare temporară de fișier Elimină din favorite - Mențiuni necitite Conexiunea pe care ați acceptat-o va fi anulată! Contactul cu care ați partajat acest link NU se va putea conecta! vrea să se conecteze cu tine! @@ -2286,7 +2271,6 @@ Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației. Deschide în aplicația mobilă, apoi atinge Conectare în aplicație.]]> Actualizează adresa - Condițiile vor fi acceptate pentru operatorii activați după 30 de zile. Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon. Colţ Puteți salva arhiva exportată. @@ -2432,11 +2416,9 @@ Te poți conecta la conversație și poți trimite mesaje imediat ce apeși pe Conectare. Rol nou în grup: Moderator Descriere scurtă: - Trimis contactului tău după conectare. Actualizezi la o adresă permanentă? Mesaj de bun venit Solicitări de contact de la grupuri - Această setare este pentru profilul tău actual Partajează adresa veche Partajează linkul vechi Se încarcă profilul… @@ -2516,7 +2498,6 @@ %1$d abonat %1$d abonați %1$s a susținut SimpleX Chat. Insigna a expirat pe %2$s. - Despre acceptată Aplicația necesita actualizare Eticheta nu poate fi verificată diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 44f3d3858e..9a4e550dbe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1328,7 +1328,6 @@ Нет истории Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. - Установка для Вашего активного профиля Установки для Вашего активного профиля Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! @@ -1853,7 +1852,6 @@ информация сервера об очереди: %1$s \n \nпоследнее полученное сообщение: %2$s - Показать список чатов в новом окне Приложение будет запрашивать подтверждение загрузки с неизвестных серверов (за исключением .onion адресов или когда SOCKS-прокси включен). Незащищённый Без Tor или VPN, Ваш IP-адрес будет доступен серверам файлов. @@ -1895,8 +1893,6 @@ Неизвестные серверы Нет Показать статус сообщения - Прямая доставка сообщений - Режим доставки сообщений Конфиденциальная доставка сообщений Цвета чата Тема чата @@ -1934,7 +1930,6 @@ Не удаётся позвонить контакту Не удаётся написать члену группы Проверять обновления - соединиться Адрес сервера назначения %1$s несовместим с настройками пересылающего сервера %2$s. Ошибка подключения к пересылающему серверу %1$s. Попробуйте позже. Пересылающий сервер %1$s не смог подключиться к серверу назначения %2$s. Попробуйте позже. @@ -2045,7 +2040,6 @@ Член группы неактивен Прямого соединения пока нет, сообщение переслано или будет переслано админом. Ничего не выбрано - открыть поиск Выбрано %d Настройки @@ -2197,7 +2191,6 @@ Нет серверов для приёма файлов. Нет серверов для отправки файлов. Недоставленные сообщения - Нажмите Создать адрес SimpleX в меню, чтобы создать его позже. Адрес или одноразовая ссылка? Безопасность соединения Операторы серверов @@ -2207,7 +2200,6 @@ %s.]]> Условия будут автоматически приняты для включенных операторов: %s. Условия приняты: %s - Вебсайт %s.]]> %s.]]> %s, примите условия использования.]]> @@ -2223,7 +2215,6 @@ Децентрализация сети Второй оператор серверов в приложении! Включите Flux в настройках Сеть и серверы для лучшей конфиденциальности метаданных. - для лучшей конфиденциальности метаданных. Улучшенная навигация в разговоре Посмотреть измененные условия Устройства Xiaomi: пожалуйста, включите опцию Autostart в системных настройках для работы нотификаций.]]> @@ -2253,12 +2244,7 @@ Нотификации и батарейка Как это улучшает конфиденциальность Операторы серверов - Выберите операторов сети. - Вы можете настроить операторов в настройках Сети и серверов. Продолжить - Посмотреть позже - Обновить - Связанные мобильные устройства Покинуть разговор? Вы прекратите получать сообщения в этом разговоре. История будет сохранена. Добавить друзей @@ -2313,7 +2299,6 @@ Приложение улучшает конфиденциальность, используя разных операторов в каждом разговоре. Когда больше чем один оператор включен, ни один из них не видит метаданные, чтобы определить, кто соединен с кем. Ошибка сохранения серверов - Условия будут приняты для включенных операторов через 30 дней. Ошибка приёма условий Соединение достигло предела недоставленных сообщений. Возможно, Ваш контакт не в сети. Чтобы защитить Вашу ссылку от замены, Вы можете сравнить код безопасности. @@ -2368,7 +2353,6 @@ Конфиденциальные названия медиафайлов. Спам Сообщения о нарушениях - Непрочитанные упоминания Да Упоминайте членов группы 👋 Улучшенная конфиденциальность и безопасность @@ -2380,7 +2364,6 @@ Установите срок хранения сообщений в чатах. Вы можете упомянуть до %1$s пользователей в одном сообщении! Причина сообщения? - Эта жалоба будет архивирована для вас. Разрешить отправлять сообщения о нарушениях модераторам. Содержание нарушает условия использования Ошибка чтения пароля базы данных @@ -2549,7 +2532,6 @@ Отправить запрос Отправить запрос без сообщения Отправляйте Ваши конфиденциальные предложения группе. - Отправляется Вашему контакту после соединения. Обновить ссылку группы? Обновить Обновить адрес? @@ -2617,7 +2599,6 @@ Удалить вместе с сообщениями Все сообщения Файлы - Фильтр Изображения Ссылки Поиск файлов @@ -2636,7 +2617,6 @@ Канал Полное имя канала: Ссылка канала - Участники канала Имя канала профиль канала обновлён Канал будет удалён для всех подписчиков - это нельзя отменить! @@ -2656,7 +2636,6 @@ Введите имя релея… Ошибка добавления релея Ошибка при создании канала - Ошибка при открытии канала ошибка: %s Ошибка при сохранении профиля канала Неверный адрес релея! @@ -2715,7 +2694,6 @@ неактивен Подпись ссылки проверена. Открыть канал - Открыть новый канал Владельцы Владелец релей @@ -2878,7 +2856,6 @@ Нижнее меню Картинка ссылки будет загружена через SOCKS-прокси. DNS-запрос может быть локальным через Ваш резолвер. Верхнее меню - Добавить Добавить релей Добавить релеи Отменить и удалить канал @@ -2887,7 +2864,6 @@ %1$d владельцев %1$d владельцев и участников канала %1$s поддерживал SimpleX Chat. Срок действия бейджа истек %2$s. - О приложении Продвинутые настройки Приложение уже запущено Необходимо обновление приложения @@ -2909,8 +2885,6 @@ Веб-страница канала Выбрано %d релеев Данные чата - Для подключения по имени канала требуется более новая версия приложения. - Для подключения по имени контакта требуется более новая версия приложения. Контакт соавтор подписчик @@ -2949,9 +2923,6 @@ Если Вы выберете Закрыть, сообщения не будут доставляться.\nВы можете изменить это позже в настройках Интерфейса. Адрес будет показан подписчикам и разрешит загрузку предпросмотра. Вступить в канал %s - Неподдерживаемое имя канала - Неподдерживаемое имя контакта - Пожалуйста, обновите приложение. Ошибка SimpleX имени Ни один из Ваших серверов не настроен для разрешения SimpleX имён. Настройте серверы или используйте ссылку для соединения. Сервер %1$s не поддерживает разрешение имён. Настройте серверы или используйте ссылку для соединения. @@ -2980,7 +2951,6 @@ Проверить имя Проверять SimpleX имена SimpleX имя не проверено - SimpleX имя Ваше SimpleX имя Установить SimpleX имя SimpleX имя %1$s зарегистрировано без ссылки канала. Добавьте ссылку канала к имени на странице регистрации. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml index 74db414936..dd8155f512 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -72,7 +72,6 @@ Prerušiť Prerušiť zmenu adresy Prerušiť zmenu adresy? - O aplikácii O operátoroch O SimpleX O adrese SimpleX @@ -82,7 +81,6 @@ Pristupovať k serverom cez SOCKS proxy na porte %d? Pred povolením tejto možnosti musí byť spustená proxy. aktívne aktívne spojenia - Pridať Pridať kontakt Pridať prispievateľov. Pridať opis @@ -367,7 +365,6 @@ Kanál nemá žiadne aktívne relé. Prosím, skúste sa pripojiť neskôr. Odkaz kanálu Odkaz kanálu - Členovia kanálu Názov kanálu Vlastnosti kanálu Kanály @@ -753,7 +750,6 @@ Tlačidlo zavrieť Porovnajte bezpečnostné kódy s vašimi kontaktmi. %s.]]> - Podmienky budú prijaté pre povolených operátorov po 30 dňoch. %s.]]> %s.]]> Podmienky budú prijaté dňa: %s. @@ -1124,7 +1120,6 @@ Súbory a médiá Status súboru: %s Súbor bude vymazaný zo serverov. - Filter Opraviť Opraviť Opraviť pripojenie @@ -1303,7 +1298,6 @@ Chyba pri načítavaní XFTP serverov Chyba pri označovaní ako prečítané Chyba pri otváraní prehliadača - Chyba pri otváraní kanálu Chyba pri otváraní chatu Chyba pri otváraní skupiny Chyba pri odmietaní žiadosti o kontakt @@ -1388,7 +1382,6 @@ Jednorazový odkaz Naraz sa dá poslať iba 10 obrázkov Naraz sa dá poslať iba 10 videí - otvoriť Otvoriť Otvoriť Otvoriť nastavenia aplikácie @@ -1400,7 +1393,6 @@ Otvoriť priečinok databázy Otvoriť externý odkaz? Otvoriť celý odkaz - Otvoriť nový kanál Otvoriť nastavenia serveru Otvoriť nastavenia Otvorte SimpleX Chat na prijatie hovoru diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index bacc84b0f7..7b05806ea6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1774,12 +1774,10 @@ Bilinmeyen sunucular Her zaman gizli yönlendirmeyi kullan. Gizli yönlendirmeyi KULLANMA. - Mesaj yönlendirme modu Hiçbir zaman Bilinmeyen sunucularla gizli yönlendirme kullan. Sürüm düşürmeye izin ver Hayır - Mesaj yönlendirme yedeklemesi Her zaman Sizin veya hedef sunucunun özel yönlendirmeyi desteklememesi durumunda bile mesajları doğrudan GÖNDERMEYİN. IP adresi korumalı olduğunda ve sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. @@ -1819,7 +1817,6 @@ Resmi kaldır Tekrarla Rengi sıfırla - Sohbet listesini yeni pencerede göster Bilinmeyen sunucular! Tor veya VPN olmadan, IP adresiniz bu XFTP yönlendiricileri tarafından görülebilir: \n%1$s. @@ -1871,11 +1868,9 @@ Mesajları ilet.. %1$s mesaj iletiliyor %1$s Mesaj kayıt ediliyor. - bağlan Ara mesaj Sadece sohbeti sil - açık ara Profil değiştirme sırasında hata oluştu. Sohbet profili seç @@ -2167,9 +2162,7 @@ Sohbet silinsin mi? Bağlantı hazır değil. Kullanım şartları - daha iyi üstveri gizliliği için. Sunucuları kaydederken hata oluştu - Şartlar 30 gün sonra etkin operatörler için kabul edilecektir. Raporu sil Listeyi değiştir %s için de geçerli olacaktır.]]> @@ -2315,7 +2308,6 @@ Güncellenen koşullar Sadece siz ve moderatörler görebilir. Sadece gönderen ve moderatörler görebilir. - Daha sonra oluşturmak için menüden SimpleX adresi oluştur seçeneğine dokunun. Operatör Dosyalar için kullanın Sohbetlerde mesajların geçerlilik süresini ayarlayın. @@ -2339,7 +2331,6 @@ İstenmeyen Uygulama, her konuşmada farklı operatörler kullanarak gizliliğinizi korur. Rol %s olarak değiştirilecek. Sohbetteki herkes bilgilendirilecek. - Okunmamış bahsetmeler Koşulları gözden geçirin Sohbet profiliniz sohbet üyelerine gönderilecektir. Sunucularınız @@ -2347,7 +2338,6 @@ Bu işlem geri alınamaz - seçilen tarihten önce bu sohbette gönderilen ve alınan mesajlar silinecektir. Bağlantının kimlerle paylaşıldığını hatırlamak için bağlantı adını ayarlayabilirsiniz. SimpleX adresini sosyal medyada paylaşın. - Web sitesi Desteklenmeyen bağlantı bağlantısı Rapor SimpleX adresi ve tek kullanımlık bağlantılar herhangi bir mesajlaşma uygulaması üzerinden güvenle paylaşılabilir. @@ -2355,9 +2345,6 @@ Giriş ayarlarını kaydetmek ister misiniz? Operatörler şunları taahhüt eder:\n- Bağımsız olun\n- Meta veri kullanımını en aza indirin\n- Doğrulanmış açık kaynak kodunu çalıştırın Birden fazla operatör etkinleştirildiğinde, hiçbirinin kimin kiminle iletişim kurduğunu öğrenmek için meta verisi yoktur. - Kullanılacak ağ operatörlerini seçin. - Güncelleme - Ağ ve sunucular ayarlarında operatörleri yapılandırabilirsiniz. Bağlantıyı aç Anahtar deposundaki parola okunamıyor, lütfen elle girin. Bu, uygulamayla uyumsuz bir sistem güncellemesinden sonra meydana gelmiş olabilir. Eğer durum bu değilse, lütfen geliştiricilerle iletişime geçin. Bu üyeyi kabul ettiniz. @@ -2368,7 +2355,6 @@ Uygulamadaki ikinci önceden ayarlanmış operatör! Adresi güncelle Gizlilik politikası ve kullanım koşulları. - Daha sonra incele Sunucu operatörleri SimpleX Chat ve Flux, Flux tarafından işletilen sunucuların uygulamaya dahil edilmesi konusunda bir anlaşma yaptı. Sohbet listesinden bağlantıları aç @@ -2409,7 +2395,6 @@ Moderatörlere gönderilen rapor Raporlarınızı yöneticilerle sohbet bölümünde görüntüleyebilirsiniz. Ayarlar aracılığıyla sunucuları yapılandırabilirsiniz. - Uzaktan kumandalı cep telefonları kapalı İnceleme üyeleri Kabul etmeden önce üyeleri inceleyin (kapıyı çalın). @@ -2428,7 +2413,6 @@ Bağlantı, teslim edilemeyen mesajların sınırına ulaştı, bağlantınız çevrimdışı olabilir. Bu bağlantı için daha yeni bir uygulama sürümü gereklidir. Lütfen uygulamayı güncelleyin veya irtibat kişinizden uyumlu bir bağlantı göndermesini isteyin. Rapor nedeni? - Rapor sizin için arşivlenecektir. Okunmamış sohbet yok Rapor: %s Raporlar @@ -2480,7 +2464,6 @@ Talep gönder Mesaj olmadan istek gönder Özel geri bildirimlerinizi gruplara gönderin. - Bağlantı kurulduktan sonra kişinize gönderilir. Grup linki güncellensin mi? Güncelle Adres güncellensin mi? @@ -2533,11 +2516,9 @@ %1$d aboneler %1$s SimpleX Chat’i destekledi. Rozet %2$s tarihinde geçerliliğini yitirdi. 4 yeni arayüz dili - Hakkında kabul edildi onaylanan kadro aktif - Ekle Röle ekle Röleler ekle Mesaj iletimini geri yüklemek için röle ekleyin. @@ -2579,7 +2560,6 @@ Kanalda aktif röle bulunmuyor. Lütfen daha sonra katılmayı deneyin. Kanal bağlantısı Kanal bağlantısı - Kanal üyeleri Kanal adı Kanal tercihleri Kanal profili abonelerin cihazlarında ve sohbet rölelerinde saklanır. @@ -2609,8 +2589,6 @@ Bağlan bağlı bağlanılıyor - Kanal adı üzerinden bağlanmak için daha yeni bir uygulama sürümü gerekir. - Kişi adı ile bağlanmak için daha yeni bir uygulama sürümü gerekir. Bağlantı başarısız Bağlantı veya kare kod ile bağlanın İletişim @@ -2658,7 +2636,6 @@ Kanal oluşturulurken hata oluştu Mesaj silinirken hata oluştu Okundu olarak işaretlenirken hata - Kanal açılırken hata oluştu hata: %s Kanal profili kaydedilirken hata oluştu İsim kaydedilirken hata oluştu @@ -2668,7 +2645,6 @@ başarısız oldu Dosyalar Bu sohbette dosya ve medya yasaktır. - Filtre Hedef sunucu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. Yönlendirme sunucusu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. Sunucu adresindeki parmak izi sertifika ile eşleşmiyor: %1$s. @@ -2741,14 +2717,12 @@ Temiz bağlantıyı aç Harici bağlantı açılsın mı? Tam bağlantıyı aç - Yeni kanal açın - bağlantı önizlemelerini göndermeyi tercih edin.\n- etkinleştirilmişse SOCKS proxy kullanın.\n- köprü kimlik avını önleyin.\n- bağlantı izlemeyi kaldırın. Ya da kare kodu şahsen veya görüntülü arama yoluyla gösterin. Ya da bu kare kodu kullanın - yazdırın veya çevrimiçi gösterin. Sahip Sahipler ve katkıda bulunanlar Sahiplik: kendi aktarıcılarınızı çalıştırabilirsiniz. - Lütfen uygulamayı güncelleyin. Önceden ayarlanmış aktarıcı adresi Önceden ayarlanmış aktarıcı adı Gizlilik: sahipler ve aboneler için. @@ -2813,7 +2787,6 @@ ⚠️ İmza doğrulama başarısız oldu: %s. SimpleX SimpleX - %d okunmamış - SimpleX adı SimpleX ad hatası SimpleX adı doğrulanmadı SimpleX Chat kitlesel fonlamasına yatırım yapan %s\'ler. @@ -2858,15 +2831,12 @@ Bu bir sohbet aktarım adresidir, bağlanmak için kullanılamaz. Bu son aktif aktarıcıdır. Kaldırılması abonelere mesaj iletimini engelleyecektir. %1$s kanalı için bağlantınız!]]> - Bu ayar mevcut profiliniz içindir Bu SimpleX adı kayıtlı değil. Lütfen ismi kontrol edin. SimpleX Ağını kalıcı kılmak için. Üst çubuk İsimleri çözümlemek için Herkes için abone engeli kaldırılsın mı? Onaylanmamış isim - Desteklenmeyen kanal adı - Desteklenmeyen kişi adı Doğrulanmamış rozet güncellenen kanal profili Adresinizi güncelleyin diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 457fd4abe1..78a0b532df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1710,7 +1710,6 @@ Джерело повідомлення залишається приватним. Завжди Завжди використовуйте приватну маршрутизацію. - Режим маршрутизації повідомлень НЕ використовуйте приватну маршрутизацію. Ні НЕ надсилайте повідомлення напряму, навіть якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. @@ -1738,7 +1737,6 @@ Учасники можуть надсилати посилання SimpleX. Звуки вхідного дзвінка Світлий режим - Запасний варіант маршрутизації повідомлень Маршрутизація приватних повідомлень переслано Інше @@ -1791,7 +1789,6 @@ Надсилайте повідомлення напряму, якщо ваш сервер або сервер призначення не підтримує приватну маршрутизацію. Встановлення теми за замовчуванням Надіслано відповідь - Показати список чату в новому вікні Використовуйте приватну маршрутизацію з невідомими серверами. Використовуйте приватну маршрутизацію з невідомими серверами, якщо IP-адреса не захищена. Фон шпалер @@ -1876,7 +1873,6 @@ Новий досвід чату 🎉 Адреса сервера Підтвердити видалення контакту? - підключитися Сервер переадресації %1$s не зміг з\'єднатися з цільовим сервером %2$s. Будь ласка, спробуйте пізніше. Повідомлення може бути доставлено пізніше, якщо учасник стане активним. Нічого не вибрано @@ -1991,7 +1987,6 @@ повідомлення дзвінок Зберегти розмову - відкрити пошук Ви все ще можете переглядати розмову з %1$s у списку чатів. Контакт видалено! @@ -2130,17 +2125,12 @@ Або поділитися приватно Оператори серверів Мережеві оператори - Умови будуть прийняті для ввімкнених операторів через 30 днів. Наприклад, якщо ваш контакт отримує повідомлення через сервер SimpleX Chat, ваш додаток доставлятиме їх через сервер Flux. - Виберіть мережевих операторів для використання. Ви можете налаштувати сервери за допомогою налаштувань. - Перегляньте пізніше - Оновлення Прийняті умови Умови будуть автоматично прийняті для увімкнених операторів: %s. Оператор мережі %s сервери - Вебсайт Ваші сервери Використовувати %s Використовувати сервери @@ -2187,7 +2177,6 @@ Продовжити Створити одноразове посилання Помилка оновлення сервера - для кращої конфіденційності метаданих. Децентралізація мережі Оператор Немає серверів для отримання повідомлень. @@ -2201,12 +2190,9 @@ Поділіться адресою SimpleX у соціальних мережах. SimpleX-адреси та одноразові посилання можна безпечно ділитися через будь-який месенджер. З\'єднання досягло ліміту недоставлених повідомлень, ваш контакт може бути офлайн. - Натисніть Створити адресу SimpleX у меню, щоб створити її пізніше. Додаток захищає вашу конфіденційність, використовуючи різних операторів у кожній розмові. Використовувати для повідомлень - Ви можете налаштувати операторів у налаштуваннях Мережі та серверів. Або імпортуйте архівний файл - Віддалені мобільні Пристрої Xiaomi: будь ласка, увімкніть Автозапуск у налаштуваннях системи, щоб сповіщення працювали.]]> Повідомлення занадто велике! Будь ласка, зменшіть розмір повідомлення або видаліть медіа та надішліть знову. @@ -2293,7 +2279,6 @@ Тільки відправник і модератори бачать це Повідомити Повідомити про порушення: тільки модератори групи побачать це. - Повідомлення буде архівоване для вас. Інша причина архівование повідомлення Архів @@ -2335,7 +2320,6 @@ Використовувати TCP-порт %1$s, якщо порт не вказано. Використовувати веб-порт Вимкнути звук - Непрочитані згадки Ви можете згадувати до %1$s учасників у кожному повідомленні! Усі звіти будуть архівовані для вас. Архівувати всі звіти? @@ -2485,7 +2469,6 @@ Надіслати запит Надіслати запит без повідомлення Надсилайте свої приватні відгуки до груп. - Відправлено вашому контакту після з\'єднання. Налаштуйте біографію профілю та вітальне повідомлення. Поділіться старою адресою Поділіться старим посиланням @@ -2550,11 +2533,9 @@ %1$d підписник %1$d підписники %1$s підтримував SimpleX Chat. Завершення дії значка %2$s. - Про підтверджено затверджений список активний - Додати Додати перемикач Додати перемикачі Додайте перемикачі для відновлення доставки повідомлень. @@ -2592,7 +2573,6 @@ Канал не має активних перемикачів. Будь ласка, спробуйте підключитися пізніше. Посилання на канал Посилання на канал - Учасники каналу Імʼя каналу Налаштування каналів Профіль каналу зберігається на пристроях підписників та на ретрансляторах чату. @@ -2622,8 +2602,6 @@ Підключитися підключено підключення - Для підключення за назвою каналу потрібна новіша версія додатка. - Для підключення за іменем контакту потрібна новіша версія додатка. Підключення не вдалося Підключитися через посилання або QR код Контакт @@ -2648,7 +2626,6 @@ Введіть URL веб-сторінки Помилка Помилка видалення повідомлення - Помилка відкриття каналу помилка: %s Помилка збереження профілю каналу Видалити реле @@ -2674,7 +2651,6 @@ помилка помилка Файли - Фільтр Відбиток в адресі цільового сервера не збігається із сертифікатом: %1$s. Щоб будь-хто міг з вами зв’язатися З історії diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index aac2109d11..4b8ec8c4b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -537,7 +537,6 @@ đã bị tắt Tin nhắn tự xóa bị cấm. gọi - kết nối Liên hệ sẽ bị xóa - điều này không thể hoàn tác! Cuộc trò chuyện đã bị xóa! Làm mờ đa phương tiện @@ -1007,7 +1006,6 @@ Làm cho một tin nhắn biến mất Tin nhắn động! Giữ lại lời mời chưa sử dụng? - Dự phòng định tuyến tin nhắn Mời bạn bè Đánh dấu đã xác thực Điều này có thể xảy ra khi:\n1. Tin nhắn hết hạn sau 2 ngày trên máy gửi hoặc sau 30 ngày trên máy chủ.\n2. Quá trình giải mã tin nhắn thất bại do bạn hoặc liên hệ của bạn sử dụng bản sao lưu cơ sở dữ liệu cũ.\n3. Kết nối bị xâm phạm. @@ -1052,7 +1050,6 @@ Tin nhắn và tệp Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm - Chế độ định tuyến tin nhắn Hãy trò chuyện trên SimpleX Chat in nghiêng Nó ảnh hưởng đến pin như thế nào @@ -1235,7 +1232,6 @@ Đang mở cơ sở dữ liệu… Hoặc hiển thị mã này Chỉ liên hệ của bạn mới có thể thực hiện cuộc gọi. - mở - tùy chọn thông báo khi xóa liên hệ.\n- tên hồ sơ với dấu cách.\n- và hơn thế nữa! Mở cài đặt Hoặc chia sẻ đường dẫn tệp này một cách an toàn. @@ -1387,7 +1383,6 @@ Lỗi trong cấu hình máy chủ. Bảo mật kết nối Tạo đường dẫn dùng một lần - Các điều kiện sẽ được chấp nhận với các nhà cung cấp được cho phép sau 30 ngày. Tiếp tục Không thể tải văn bản về các điều kiện hiện tại, bạn có thể xem xét các điều kiện thông qua đường dẫn này: Các điều kiện sử dụng @@ -1421,7 +1416,6 @@ Mời để trò chuyện Rời cuộc trò chuyện Cho định tuyến riêng tư - để bảo mật siêu dữ liệu tốt hơn. Đã cải thiện điều hướng trò chuyện Xóa cuộc trò chuyện Cho mạng xã hội @@ -1532,7 +1526,6 @@ Làm mới Đã giảm mức sử dụng pin Từ chối - Các thiết bị di động từ xa Xóa Xóa Xóa thành viên @@ -1617,7 +1610,6 @@ Gửi tin nhắn trực tiếp để kết nối Đang lưu %1$s tin nhắn (quét hoặc dán từ bảng nháp) - Chọn các bên vận hành mạng lưới để sử dụng. Gửi tin nhắn trực tiếp Lưu lời chào? gửi thất bại @@ -1628,7 +1620,6 @@ Lưu danh sách Thử lại Lưu và thông báo tới liên hệ - Xem xét lại sau mã bảo mật đã thay đổi Lưu hồ sơ nhóm Lưu và kết nối lại @@ -1750,7 +1741,6 @@ Hiển thị bảng điều khiển trong cửa sổ mới Lỗi tạo báo cáo Chia sẻ tin nhắn… - Hiển thị danh sách trò chuyện trong cửa sổ mới Chỉ hiển thị liên hệ Chia sẻ đường dẫn dùng 1 lần này Chia sẻ với các liên hệ @@ -1902,7 +1892,6 @@ Hệ thống Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Nhấn để Kết nối - Nhấn Tạo địa chỉ SimpleX ở menu để tạo sau. Nhấn để tham gia một cách ẩn danh Hệ thống Chuyển đổi âm thanh và video trong lúc gọi. @@ -1977,7 +1966,6 @@ Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. Những máy chủ cho các kết nối mới của hồ sơ trò chuyện hiện tại của bạn - Báo cáo sẽ được lưu trữ cho bạn. Tin nhắn này đã bị xóa hoặc vẫn chưa được nhận. Mã QR này không phải là một đường dẫn! Đường dẫn này không phải là một đường dẫn kết nối hợp lệ! @@ -2067,7 +2055,6 @@ Nâng cấp ứng dụng một cách tự động Đã tải lên Cập nhật - Cập nhật Mở khóa Xác minh bảo mật kết nối Sử dụng cho các tệp @@ -2131,7 +2118,6 @@ qua đường dẫn địa chỉ liên lạc Video Chúng tôi không lưu bất kỳ liên hệ hay tin nhắn nào của bạn (một khi đã được gửi) trên các máy chủ. - Website Xin chào %1$s! Khi có sẵn Các tin nhắn thoại bị cấm! @@ -2204,7 +2190,6 @@ Bạn đã kết nối với %1$s rồi. Bạn cho phép Bạn không được kết nối với các máy chủ này. Định tuyến riêng tư được sử dụng để gửi tin nhắn tới chúng. - Bạn có thể định cấu hình các bên vận hành trong cài đặt Mạng & máy chủ. Bạn đang kết nối thông qua đường dẫn dùng một lần này! Bạn có thể thử một lần nữa. Bạn được mời vào nhóm. Tham gia để kết nối với các thành viên nhóm. @@ -2310,7 +2295,6 @@ Sử dụng cổng web Bạn có thể nhắc đến tối đa là %1$s thành viên trong một tin nhắn! Tắt thông báo tất cả - Những lời nhắc chưa đọc Các thành viên có thể báo cáo tin nhắn tới các kiểm duyệt viên. Tất cả các báo cáo sẽ được lưu trữ cho bạn. Lưu trữ tất cả báo cáo? @@ -2442,8 +2426,6 @@ đã kết nối Kết nối nhanh hơn 🚀 đang kết nối - Kết nối bằng tên kênh yêu cầu phiên bản ứng dụng mới hơn - Kết nối bằng tên liên hệ yêu cầu phiên bản ứng dụng mới hơn. Kết nối thất bại Kết nối bằng đường dẫn hoặc mã QR Liên hệ @@ -2465,7 +2447,6 @@ Lỗi tạo kênh Lỗi khi xoá tin nhắn Lỗi khi đánh dấu \"đã xem\" - Lỗi mở kênh Lỗi khi mở đoạn chat Lỗi khi mở nhóm Lỗi khi từ chối lời mời kết nối @@ -2481,13 +2462,11 @@ Để ai đó kết nối với bạn Đường dẫn Đang tải hồ sơ… - Giới thiệu Đồng ý yêu cầu kết nối Đồng ý yêu cầu kết nối đã chấp nhận danh sách đã xác nhận đang hoạt động - Thêm mới Thêm tin nhắn Thêm thiết bị Thêm mới thiết bị diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 831da9ca8c..2d5ccbe4b9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1763,7 +1763,6 @@ 当你的服务器或目标服务器不支持私密路由时直接发送消息。 - 备用消息路由 显示消息状态 为了保护你的 IP 地址,私密路由使用你的 SMP 服务器来传送消息。 私密消息路由 @@ -1776,7 +1775,6 @@ 不直接发送消息,即便你的服务器或目标服务器不支持私密路由。 转发服务器:%1$s \n目标服务器错误:%2$s - 消息路由模式 未知服务器! 没有 Tor 或 VPN,这些 XFTP 中继可以看到你的 IP 地址: \n%1$s. @@ -1785,7 +1783,6 @@ 文件 应用将请求确认来自未知服务器的下载(.onion 或启用 SOCKS 代理时除外)。 个人资料主题 - 在新窗口中显示聊天列表 所有颜色模式 应用到 @@ -1994,10 +1991,8 @@ 无法呼叫群成员 无法给群成员发消息 确认删除联系人? - 连接 邀请 没有过滤的联系人 - 打开 搜索 发送消息来开启通话。 你可以发消息给来自已存档联系人的 %1$s。 @@ -2123,11 +2118,7 @@ 或者私下分享 服务器运营方 网络运营方 - 30 天后将接受已启用的运营方的条款。 继续 - 稍后审阅 - 选择要使用的网络运营方。 - 更新 你可以通过设置配置服务器。 %s.]]> 将于下列日期自动接受已启用的运营方的条款:%s。 @@ -2137,7 +2128,6 @@ 网络运营方 运营方 %s 台服务器 - 网站 无法加载当前条款文本,你可以通过此链接审阅条款: 使用 %s 使用服务器 @@ -2180,7 +2170,6 @@ %s.]]> 在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。 接受条款出错 - 为了更好的元数据隐私。 添加服务器出错 无媒体和文件服务器。 更新服务器出错 @@ -2189,14 +2178,11 @@ - 在第一条未读消息上打开聊天.\n- 跳转到引用的消息. 审阅条款 SimpleX 地址或一次性链接? - 要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址” 当前聊天资料的新文件服务器 此消息被删除或尚未收到。 连接达到了未送达消息上限,你的联系人可能处于离线状态。 为了防止链接被替换,你可以比较联系人安全代码。 - 你可以在“网络和服务器”设置中配置运营方。 接受运营方条款的日期:%s - 远程移动设备 或者导入存档文件 小米设备:请在系统设置中开启“自动启动”让通知正常工作。]]> 消息太大! @@ -2294,7 +2280,6 @@ 垃圾信息 存档举报? 举报理由? - 将为你存档该举报。 存档了 %s 的举报 举报 1 个举报 @@ -2324,7 +2309,6 @@ 未指定端口时,使用 TCP 端口 %1$s。 使用 web 端口 全部静音 - 取消提及的已读状态 每条消息最多提及 %1$s 名成员! 将为你存档所有举报。 存档所有举报? @@ -2450,7 +2434,6 @@ 发送联络请求? 发送请求 发送无消息请求 - 连接后发送给你的联系人。 升级群链接? 升级 升级地址? @@ -2504,7 +2487,6 @@ 使用隐身个人资料 欢迎联系人👋 来自%1$s群的连接请求 - 此设置用于当前个人资料 群内的联络请求 成员被删除——无法接受请求 只有你的联系人允许的情况下才允许文件和媒体。 @@ -2536,7 +2518,6 @@ 移除并删除消息 所有消息 文件 - 筛选器 图片 链接 搜索文件 @@ -2567,7 +2548,6 @@ 频道 频道 频道链接 - 频道成员 频道名 将为所有订阅者删除频道 —— 此操作无法撤销! 将为你删除频道 —— 此操作无法撤销! @@ -2597,7 +2577,6 @@ 输入中继名… 添加中继出错 创建频道出错 - 打开频道出错 失败 失败 获取链接 @@ -2614,7 +2593,6 @@ 未启用聊天中继。 不是所有中继均已连接 打开频道 - 打开新频道 所有者 所有者和贡献者 预设中继地址 @@ -2792,7 +2770,6 @@ 顶部栏 你的新频道 %1$s 已连接到 %3$d 个中继中的 %2$d 个中继。\n如果取消,频道将被删除 —— 你可以再次创建它。 这是上次活跃的中继。删除它会阻止给订阅者传送消息。 - 添加 添加中继 添加中继 取消并删除频道 @@ -2831,12 +2808,9 @@ %1$d 名所有者 %1$d 名所有者和贡献者 %1$s 支持过 SimpleX Chat。该徽章已于 %2$s 过期。 - 关于 无法验证徽章 频道网页 聊天数据 - 通过频道名连接需要更新的应用版本。 - 通过联系人名称连接需要更新的应用版本。 联系人 贡献者 复制代码 @@ -2847,7 +2821,6 @@ 它将展示给订阅者并用来允许加载预览。 更多隐私 只有您上面的页面可以显示预览。 - 请升级应用 %s 给 SimpleX Chat 众筹投过钱。 %s 支持 SimpleX Chat。 订阅者 @@ -2855,8 +2828,6 @@ 该角色将更改为 "%s"。频道中的每个人都会收到通知。 此徽章无法被验证,可能不是真的。 此群组需要更新版本的应用。要加入请更新应用。 - 不支持的频道名 - 不支持的联系人名 未验证的徽章 所用的聊天中继不支持网页。 网页代码 @@ -2879,7 +2850,6 @@ 无有效链接 解析错误:%1$s 设置 SimpleX 名称 - SimpleX 名称 SimpleX 名称错误 SimpleX 名称未验证 SimpleX 名称 %1$s 已注册但没有有效链接。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 05e3cd8030..2909f01636 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1437,7 +1437,6 @@ 禁止檔案和媒體! 檔案載入中 檔案錯誤 - 備用訊息路由 如果你或你的目標伺服器不支持私密路由,將不直接傳送訊息。 建立個人檔案 從另一台裝置轉移 @@ -1533,7 +1532,6 @@ 中止地址更改時出錯 顯示通知出錯,請聯繫開發者。 刪除並通知聯絡人 - 訊息路由模式 不使用私密路由。 要進行通話請授予一項或多項權限 在 Android 系統設定中找到此權限並手動授予權限。 @@ -1664,7 +1662,6 @@ 成員非活躍 訊息 訊息將被標記為刪除。收信人可以揭示這些訊息。 - 連接 訊息 停用 下載更新中,請不要關閉應用 @@ -1754,7 +1751,6 @@ 私密路由出錯 尚無直接連接,訊息由管理員轉發。 什麼也沒選中 - 開啟 私密路由 打開資料庫文件夾 私密訊息路由 @@ -1859,7 +1855,6 @@ 喇叭 解除封鎖 - 網站 透明度 重複 編輯 @@ -1867,7 +1862,6 @@ 選擇 使用者名稱 密碼 - 更新 繼續 已拒絕 系統 @@ -2028,7 +2022,6 @@ 聯絡人已刪除 未同步 聯絡人已停用 - 稍後審核 分享個人檔案 完整連結 已上載檔案 @@ -2077,7 +2070,6 @@ 你已封鎖 %s 連接未就緒。 更好的訊息日期。 - 為了更好的元資料隱私。 你可以再試一次。 使用匿名個人檔案 開啟聊天 @@ -2137,7 +2129,6 @@ 影片 檔案 連結 - 過濾器 %d 個舉報 已棄用的選項 無訂閱 @@ -2226,11 +2217,6 @@ 刪除聊天時發生錯誤 不支援的連線連結 此連結需要較新的應用程式版本。請升級應用程式,或請你的聯絡人傳送相容的連結。 - 不支援的頻道名稱 - 不支援的聯絡人名稱 - 透過頻道名稱連線需要較新的應用程式版本。 - 透過聯絡人名稱連線需要較新的應用程式版本。 - 請升級應用程式。 頻道暫時無法使用 頻道沒有啟用中的中繼。請稍後再嘗試加入。 需要更新應用程式 @@ -2250,7 +2236,6 @@ 應用程式電池用量 / 不受限制。]]> Xiaomi 裝置:請在系統設定中啟用自動啟動,以便通知正常運作。]]> 此訊息已刪除或尚未接收。 - 該檢舉報告將為你封存。 所有檢舉報告將為你封存。 金鑰錯誤或未知連線——此連線很可能已刪除。 伺服器地址與網路設定不相容。 @@ -2273,7 +2258,6 @@ 讓任何人都能聯絡你 列表 %s 中沒有聊天。 聯絡人需要接受… - 稍後可點選選單中的「建立 SimpleX 地址」來建立。 沒有可轉發的內容! 要轉發不含檔案的訊息嗎? 這些訊息在你選取後已被刪除。 @@ -2341,7 +2325,6 @@ 拒絕聯絡請求 傳送者不會收到通知。 成員已刪除,無法接受請求 - 未讀提及 所有列表的名稱和 emoji 都應不同。 所有聊天都將從列表 %s 中移除,且該列表會被刪除 在社交媒體上分享 SimpleX 地址。 @@ -2380,7 +2363,6 @@ 若要接收新版本通知,請開啟穩定版或 Beta 版的定期檢查。 顯示較慢的 API 呼叫 儲存設定時發生錯誤 - 連線後會傳送給你的聯絡人。 或私下分享 SimpleX 地址還是一次性連結? 新的一次性連結 @@ -2429,11 +2411,8 @@ 應用程式會在每個對話中使用不同營運商,以保護你的私隱。 啟用多於一個營運商時,沒有任何一方擁有足以得知誰與誰通訊的中繼資料。 例如,如果你的聯絡人透過 SimpleX Chat 伺服器接收訊息,你的應用程式會透過 Flux 伺服器傳送訊息。 - 選擇要使用的網路營運商。 這如何有助於私隱 你可以透過設定配置伺服器。 - 30 天後,將會接受已啟用營運商的條件。 - 你可以在「網路與伺服器」設定中配置營運商。 你的網路 網路路由器無法知道\n誰在和誰交談 設定路由器 @@ -2450,7 +2429,6 @@ 應用程式會要求確認來自未知檔案伺服器的下載(.onion 或啟用 SOCKS 代理時除外)。 未使用 Tor 或 VPN 時,你的 IP 地址會對檔案伺服器可見。 移除連結追蹤 - 此設定適用於你目前的個人檔案 這些設定適用於你目前的個人檔案 可在聯絡人和群組設定中覆寫這些設定。 已為 %d 個聯絡人啟用送達回條 @@ -2464,14 +2442,12 @@ 開啟乾淨連結 傳送送達回條給 來自群組的聯絡請求 - 關於 聯絡 支持此專案 聊天數據 說明與支援 更多私隱 進階設定 - 遠端行動裝置 你可以儲存匯出的封存檔。 你可以遷移匯出的數據庫。 部分檔案未匯出 @@ -2486,7 +2462,6 @@ 底部列 頂部列 在新視窗中顯示控制台 - 在新視窗中顯示聊天列表 如果你加入或建立了頻道,它們將永久停止運作。 要離開頻道嗎? 你將停止接收此頻道的訊息。聊天記錄將會保留。 @@ -2535,7 +2510,6 @@ 只有聊天擁有者可以變更偏好設定。 此群組有超過 %1$d 名成員,不會傳送送達回條。 連結 - 頻道成員 聊天中繼 要移除訂閱者嗎? 要刪除成員訊息嗎? @@ -2827,7 +2801,6 @@ 你的個人檔案 %1$s 將與頻道中繼和訂閱者共享。\n中繼可以存取頻道訊息。 設定中繼 失敗 - 新增 新增中繼 新增中繼 沒有可用的中繼 @@ -2843,10 +2816,8 @@ 中繼地址 這是聊天中繼地址,不能用於連接。 開啟頻道 - 開啟新頻道 你的頻道 %1$s 的連結!]]> - 開啟頻道時發生錯誤 要對全部解除封鎖訂閱者嗎? 要啟用連結預覽嗎? 傳送連結預覽可能會向網站透露你的 IP 地址。你可以稍後在「私隱」設定中變更此設定。 diff --git a/flake.nix b/flake.nix index fdd041bd88..0863222c92 100644 --- a/flake.nix +++ b/flake.nix @@ -405,6 +405,7 @@ "chat_send_remote_cmd" "chat_send_remote_cmd_retry" "chat_valid_name" + "chat_parse_badge_code" "chat_json_length" "chat_badge_keygen" "chat_badge_issue" @@ -526,6 +527,7 @@ "chat_send_remote_cmd" "chat_send_remote_cmd_retry" "chat_valid_name" + "chat_parse_badge_code" "chat_json_length" "chat_badge_keygen" "chat_badge_issue" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index ec4125193f..a7a66992a6 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -15,6 +15,7 @@ EXPORTS chat_parse_uri chat_password_hash chat_valid_name + chat_parse_badge_code chat_json_length chat_badge_keygen chat_badge_issue diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b2d1f89882..3e6fb7e229 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -53,6 +53,7 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConf import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Entitlement (entitlementIssuerKeys) +import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) import qualified Simplex.Messaging.TMap as TM import qualified UnliftIO.Exception as E @@ -68,7 +69,7 @@ defaultChatConfig = }, chatVRange = supportedChatVRange, badgePublicKeys = M.mapKeys fromIntegral entitlementIssuerKeys, - badgeServiceAddress = Nothing, + badgeServiceAddress = Just $ either error id $ strDecode "https://smp5.simplex.im/a#ooSNWlEZTO2RPE0Ff5ZoybAs5zEhWLMlQrXesnhaZHM", badgeCurrentTime = getCurrentTime, badgeRetryInterval = RetryInterval {initialInterval = 30_000000, increaseAfter = 0, maxInterval = 3600_000000}, confirmMigrations = MCConsole, diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index cbd2ecb1b6..a0ee90b83c 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -36,6 +36,7 @@ import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) import Simplex.Chat.PaymentService.Types (InvoiceId, PaymentId, StoredPayment) +import Simplex.Chat.Types (BoolDef (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Crypto as C @@ -212,6 +213,7 @@ data BadgeAlert = BadgeAlert data BadgeState = BadgeState { badgePurchaseId :: Int64, badgeType :: BadgeType, + shown :: BoolDef, monthsLeft :: Int, paidThrough :: UTCTime, -- payments returns here with the payment types, which this slice neither writes nor encodes diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e41d05f1db..e0f077fbb3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3563,7 +3563,7 @@ processChatCommand cxt nm = \case ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText - APIGetBadgeState userId -> withUserId userId $ \user -> do + APIGetBadgeState userId -> withUserId' userId $ \user -> do -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user CRBadgeState user <$> getUserBadgeState user @@ -5407,10 +5407,11 @@ getUserBadgeState user = do Just p@UserBadgePurchase {badgePurchaseId} -> fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) where - badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType} balance = + badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType, shown} balance = BadgeState { badgePurchaseId, badgeType, + shown = BoolDef shown, monthsLeft = balanceMonths balance, paidThrough = L.paidThrough balance, renewsAt = Nothing, diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 51e0409723..d2e1d8460d 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -27,6 +27,7 @@ import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8) import Foreign.C.String import Foreign.C.Types (CInt (..)) @@ -35,6 +36,7 @@ import Foreign.StablePtr import Foreign.Storable (poke) import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEncoding) import Simplex.Chat +import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Controller import Simplex.Chat.Library.Commands import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) @@ -137,6 +139,8 @@ foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CStrin foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString +foreign export ccall "chat_parse_badge_code" cChatParseBadgeCode :: CString -> IO CString + foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt foreign export ccall "chat_badge_keygen" cChatBadgeKeygen :: IO CJSONString @@ -240,6 +244,12 @@ cChatPasswordHash cPwd cSalt = do cChatValidName :: CString -> IO CString cChatValidName cName = newCString . mkValidName =<< peekCString cName +-- | canonical form of a code that passes its check character, empty string if it does not parse +cChatParseBadgeCode :: CString -> IO CString +cChatParseBadgeCode cCode = do + code <- safeDecodeUtf8 <$> B.packCString cCode + newCStringFromBS $ maybe "" (encodeUtf8 . badgeCodeText) $ parseBadgeCode code + -- | returns length of JSON encoded string cChatJsonLength :: CString -> IO CInt cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s