diff --git a/.github/workflows/android-emu.yml b/.github/workflows/android-emu.yml index 3bc26a5a50..33ded0dcc2 100644 --- a/.github/workflows/android-emu.yml +++ b/.github/workflows/android-emu.yml @@ -177,7 +177,7 @@ jobs: name: Android app in the emulator needs: [plan, core, backend] # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it - if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.core.result != 'cancelled' && needs.backend.result != 'cancelled' }} runs-on: ubuntu-24.04 timeout-minutes: 75 steps: @@ -250,13 +250,13 @@ jobs: unzip -l "$RUNNER_TEMP/apk/$APK" | grep -E ' lib/' - name: Upload app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: android-app path: ${{ runner.temp }}/apk/${{ env.APK }} compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Install emulator and system image diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7cb6913671..f680c5fe88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,12 @@ on: - "simplex-chat.cabal" - "cabal.project" - "Dockerfile*" - - "scripts/ci/**" + - "scripts/ci/*.sh" - "scripts/ci/ios-stub-libs.sh" - "scripts/desktop/**" - - ".github/**" + - ".github/workflows/build.yml" + - ".github/actions/**" + - ".github/changelog_conf.json" - "packages/simplex-chat-nodejs/**" - "packages/simplex-chat-client/types/typescript/**" - "packages/simplex-chat-python/**" @@ -54,7 +56,7 @@ env: release: ${{ startsWith(github.ref, 'refs/tags/v') }} branch: ${{ github.base_ref || github.ref_name }} cache_o0: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') }} - release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.ref_name || '' }} + release_branch: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/stable') && github.repository == 'simplex-chat/simplex-chat' && github.ref_name || '' }} ZSTD_CLEVEL: 9 # This workflow uses custom actions (prepare-build and prepare-release) defined in: @@ -124,7 +126,7 @@ jobs: publish: ${{ steps.publish.outputs.nodejs_version || steps.publish.outputs.python_version }} packages: ${{ steps.changes.outputs.packages }} scopes: ${{ steps.changes.outputs.scopes }} - reduced: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == true }} + reduced: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.draft == true || (github.event_name == 'push' && github.ref_type == 'branch' && github.repository != 'simplex-chat/simplex-chat') }} steps: - name: Reject a manual run on a tag if: github.event_name == 'workflow_dispatch' && github.ref_type == 'tag' @@ -141,14 +143,12 @@ jobs: id: changes shell: bash env: - BASE: ${{ github.event.pull_request.base.sha }} BASE_BRANCH: ${{ inputs.base }} GH_TOKEN: ${{ github.token }} run: | case "$GITHUB_EVENT_NAME" in pull_request) - git fetch -q --depth=1 origin "$BASE" - python3 scripts/ci/packages_release.py changes "$BASE" + python3 scripts/ci/packages_release.py changes HEAD^1 ;; workflow_dispatch) base=$(gh api "repos/$GITHUB_REPOSITORY/compare/$BASE_BRANCH...$GITHUB_REF_NAME" --jq .merge_base_commit.sha) @@ -157,7 +157,7 @@ jobs: ;; *) echo "haskell=true" - if [ "$GITHUB_REF_TYPE" = branch ]; then + if [ -n "$release_branch" ]; then echo "kotlin=true" echo "ios=true" python3 scripts/ci/packages_release.py scopes @@ -643,7 +643,7 @@ jobs: shell: bash run: | mkdir -p tests/tmp - sudo mount -t tmpfs -o size=512M tmpfs tests/tmp + sudo mount -t tmpfs -o size=2G tmpfs tests/tmp sudo chown $(id -u):$(id -g) tests/tmp - name: Run tests @@ -807,7 +807,7 @@ jobs: build-linux-push: name: "ubuntu-${{ matrix.os }}-${{ matrix.arch }} (CLI,Desktop), GHC: ${{ matrix.ghc }}" needs: [maybe-release, variables] - if: github.event_name == 'push' + if: needs.maybe-release.outputs.reduced != 'true' && github.event_name == 'push' runs-on: ${{ matrix.runner }} permissions: contents: write @@ -851,6 +851,9 @@ jobs: env: image_cache: ghcr.io/${{ github.repository_owner }}/simplex-chat-build-cache:${{ matrix.os }}-${{ matrix.arch }}-ghc${{ matrix.ghc }} source_hashes: dist-newstyle/sources.sha256 + opt: O0 + ghc_options: --ghc-options=-O0 + cache_o0: false concurrency: *linux-concurrency strategy: fail-fast: false @@ -1215,7 +1218,7 @@ jobs: if: matrix.should_run == true && matrix.arch == 'aarch64' shell: bash run: | - disk=$(hdiutil attach -nomount ram://1048576 | tr -d '[:space:]') + disk=$(hdiutil attach -nomount ram://4194304 | tr -d '[:space:]') diskutil erasevolume HFS+ tests "$disk" ln -s /Volumes/tests tests/tmp @@ -1366,6 +1369,10 @@ jobs: shell: bash run: sudo ln -sfn "$(xcrun --sdk macosx --show-sdk-path)" /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk + - name: Check the previews listed in the snapshot tests + shell: bash + run: python3 "apps/ios/SimpleX Snapshots/check-previews.py" + - name: Build iOS app timeout-minutes: 60 shell: bash diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml index 82725791f5..eff5767152 100644 --- a/.github/workflows/desktop-linux.yml +++ b/.github/workflows/desktop-linux.yml @@ -183,7 +183,7 @@ jobs: name: desktop app under Xvfb needs: [plan, core, backend] # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it - if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.core.result != 'cancelled' && needs.backend.result != 'cancelled' }} runs-on: ubuntu-24.04 timeout-minutes: 60 steps: @@ -242,41 +242,48 @@ jobs: distribution: corretto java-version: ${{ env.JAVA_VER }} + # the Kotlin compiler daemon otherwise gets gradle.properties' 2 GB, which compiling :common runs out of - name: Build app id: build if: ${{ !cancelled() && steps.core.outcome == 'success' }} working-directory: apps/multiplatform run: | - ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :desktop:createDistributable + ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :desktop:createDistributable app=release/main/app/simplex ls -la "$app/bin" "$app/lib/app/resources" du -sh "$app" LD_LIBRARY_PATH="$app/lib/app/resources" ldd "$app/lib/app/resources/libapp-lib.so" - # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + # a tarball keeps the app's file modes, which an artifact drops - name: Pack app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: tar -czf "$RUNNER_TEMP/desktop-app.tar.gz" -C apps/multiplatform/release/main/app simplex - name: Upload app - if: ${{ !cancelled() && steps.build.outcome == 'success' && needs.plan.outputs.run-claude == 'true' }} + if: ${{ !cancelled() && steps.build.outcome == 'success' }} uses: actions/upload-artifact@v7 with: name: desktop-app path: ${{ runner.temp }}/desktop-app.tar.gz compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Run desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} working-directory: apps/multiplatform - run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets :common:desktopTest + env: + SNAPSHOT_DIR: ${{ runner.temp }}/snapshots + run: ./gradlew --no-daemon --console=plain -Psimplex.assets.dir=../../assets -Pkotlin.daemon.jvmargs=-Xmx4g :common:desktopTest - name: Report desktop tests if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: scripts/ci/desktop-linux/test-summary.py apps/multiplatform/common/build/test-results/desktopTest + - name: Compare snapshots + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: python3 scripts/ci/snapshots-compare.py apps/multiplatform/common/src/desktopTest/snapshots "$RUNNER_TEMP/snapshots" "$RUNNER_TEMP/snapshots-diff" + - name: Run smoke test if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.backend.outcome != 'failure' }} env: @@ -312,6 +319,17 @@ jobs: retention-days: 3 if-no-files-found: warn + - name: Upload snapshots + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: desktop-snapshots + path: | + ${{ runner.temp }}/snapshots + ${{ runner.temp }}/snapshots-diff + retention-days: 3 + if-no-files-found: warn + - name: Upload backend logs if: ${{ !cancelled() && steps.backend.outcome != 'skipped' }} uses: actions/upload-artifact@v7 @@ -329,7 +347,7 @@ jobs: needs: [plan, app] if: ${{ !cancelled() && needs.plan.outputs.run-claude == 'true' && needs.app.result == 'success' }} runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 120 environment: name: app-sim-claude deployment: false diff --git a/.github/workflows/ios-sim.yml b/.github/workflows/ios-sim.yml index 08502674d9..1fe3fe5791 100644 --- a/.github/workflows/ios-sim.yml +++ b/.github/workflows/ios-sim.yml @@ -184,7 +184,7 @@ jobs: name: app in the iOS simulator needs: [plan, core, backend] # core and backend are skipped when an earlier run built them, and backend also when the run doesn't start it - if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' }} + if: ${{ !cancelled() && !failure() && needs.plan.result == 'success' && needs.core.result != 'cancelled' && needs.backend.result != 'cancelled' }} runs-on: macos-26 timeout-minutes: 120 env: @@ -249,19 +249,17 @@ jobs: DEVELOPMENT_TEAM= \ PROVISIONING_PROFILE_SPECIFIER= - # for the Claude job; a tarball keeps the app's file modes, which an artifact drops + # a tarball keeps the app's file modes, which an artifact drops - name: Pack app - if: needs.plan.outputs.run-claude == 'true' run: tar -czf "$RUNNER_TEMP/SimpleX.app.tar.gz" -C "$RUNNER_TEMP/DerivedData/Build/Products/Debug-iphonesimulator" SimpleX.app - name: Upload app - if: needs.plan.outputs.run-claude == 'true' uses: actions/upload-artifact@v7 with: name: ios-sim-app path: ${{ runner.temp }}/SimpleX.app.tar.gz compression-level: 0 - retention-days: 1 + retention-days: 14 if-no-files-found: error - name: Install AXe diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index d40ef7d625..12fc5f5862 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -31,14 +31,12 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v6 + with: + fetch-depth: 2 - name: Build plan id: plan - env: - BASE: ${{ github.event.pull_request.base.sha }} - run: | - git fetch -q --depth=1 origin "$BASE" - python3 scripts/ci/packages_release.py build-plan --base "$BASE" >> "$GITHUB_OUTPUT" + run: python3 scripts/ci/packages_release.py build-plan --base HEAD^1 >> "$GITHUB_OUTPUT" build: name: "build ${{ matrix.platform }}" diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 5fbe8293bc..7628eb1f9a 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -33,6 +33,7 @@ jobs: ./website/web.sh - name: Deploy + if: github.repository == 'simplex-chat/simplex-chat' uses: simplex-chat/actions-gh-pages@v3 with: publish_dir: ./website/_site diff --git a/apps/ios/CLAUDE.md b/apps/ios/CLAUDE.md index 99ee350f47..a75cbe027a 100644 --- a/apps/ios/CLAUDE.md +++ b/apps/ios/CLAUDE.md @@ -1 +1,13 @@ @README.md + +## Swift code + +- An extension is written only where Swift has no other form: + - a SwiftUI environment value, which SwiftUI requires as a property of EnvironmentValues; + - a conformance, required by an API, of a type declared in another module. +- Members of a type declared in this project are declared in the body of that type, not in an extension. +- A member of a SimpleXChat type is declared in that type; a value it needs from the app is passed as a parameter. +- A view modifier is a ViewModifier type applied with `.modifier(_:)`. +- A custom alignment is a static constant of a non-generic type that uses it. +- Functions and constants at file scope are kept at file scope. +- A new member is declared in its type even when the file already extends that type. Existing extensions are moved only in a task that concerns them. diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index a7bcacbed7..f5f88bd4c0 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -193,7 +193,8 @@ enum ChatCommand: ChatCmdProtocol { case apiStandaloneFileInfo(url: String) // badges case apiRedeemBadgeCode(userId: Int64, code: String) - case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, payment: ServicePayment) + case apiPurchaseBadge(userId: Int64, echoedInvoiceId: String?, origin: StoreReceiptOrigin, payment: ServicePayment) + case apiSetBadgeSubscriptionStatus(status: BadgeSubscriptionState, payment: ServicePayment) case apiCreateBadgeInvoice(userId: Int64) case apiGetBadgeState(userId: Int64) case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) @@ -421,8 +422,9 @@ enum ChatCommand: ChatCmdProtocol { 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 .apiPurchaseBadge(userId, echoedInvoiceId, payment): - return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "") \(encodeJSON(payment))" + case let .apiPurchaseBadge(userId, echoedInvoiceId, origin, payment): + return "/_badge purchase \(userId)\(echoedInvoiceId.map { " invoice=\($0)" } ?? "")\(storeReceiptOriginParam(origin)) \(encodeJSON(payment))" + case let .apiSetBadgeSubscriptionStatus(status, payment): return "/_badge subscription \(status.text) \(encodeJSON(payment))" case let .apiCreateBadgeInvoice(userId): return "/_badge invoice \(userId)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" @@ -617,6 +619,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" case .apiPurchaseBadge: return "apiPurchaseBadge" + case .apiSetBadgeSubscriptionStatus: return "apiSetBadgeSubscriptionStatus" case .apiCreateBadgeInvoice: return "apiCreateBadgeInvoice" case .apiGetBadgeState: return "apiGetBadgeState" case .apiGetBadgeLedger: return "apiGetBadgeLedger" @@ -677,8 +680,10 @@ enum ChatCommand: ChatCmdProtocol { // a code or a store receipt 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)) - case let .apiPurchaseBadge(userId, echoedInvoiceId, .apple(jws)): - return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: .apple(jws: obfuscate(jws))) + case let .apiPurchaseBadge(userId, echoedInvoiceId, origin, .apple(jws)): + return .apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, origin: origin, payment: .apple(jws: obfuscate(jws))) + case let .apiSetBadgeSubscriptionStatus(status, .apple(jws)): + return .apiSetBadgeSubscriptionStatus(status: status, payment: .apple(jws: obfuscate(jws))) default: return self } } @@ -705,15 +710,25 @@ enum ChatCommand: ChatCmdProtocol { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } - // /_badge ack takes the kind in core's text encoding, not the JSON tag + // /_badge ack takes the kind in core's text encoding, not the JSON tag; an unknown kind has no banner to dismiss, so it is never acked private func badgeAlertKindParam(_ kind: BadgeAlertKind) -> String { switch kind { case .renewalApproaching: "renewal_approaching" case .paymentIssue: "payment_issue" + case .subscriptionCancelled: "subscription_cancelled" case .subscriptionEnded: "subscription_ended" case .prepaidEnding: "prepaid_ending" case .supportEnded: "support_ended" case .issueFailed: "issue_failed" + case let .unknown(s): s + } + } + + private func storeReceiptOriginParam(_ origin: StoreReceiptOrigin) -> String { + switch origin { + case .purchased: "" + case .listed: " listed" + case .restored: " restored" } } @@ -745,6 +760,14 @@ enum ServicePayment: Encodable { } } +enum StoreReceiptOrigin { + case purchased + // found in the store's list rather than bought or restored here, so core records it only into a record it has + case listed + // Restore: core claims it for this profile, moving it from another device or profile it was credited to + case restored +} + struct OpenStorePurchase: Decodable { var invoiceId: String? var transactionRef: String? @@ -1084,6 +1107,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) case badgeInvoice(user: UserRef, invoiceId: String) + case storeReceiptNotRecorded(user: UserRef) case badgeState(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]?) case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) @@ -1139,6 +1163,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" case .badgeInvoice: "badgeInvoice" + case .storeReceiptNotRecorded: "storeReceiptNotRecorded" case .badgeState: "badgeState" case .badgeLedger: "badgeLedger" } @@ -1196,6 +1221,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") case let .badgeInvoice(u, invoiceId): return withUser(u, invoiceId) + case let .storeReceiptNotRecorded(u): return withUser(u, "") case let .badgeState(u, badgeState, storePurchases): return withUser(u, "\(String(describing: badgeState))\nstorePurchases: \(String(describing: storePurchases ?? []))") case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 704821d088..30f160ebe5 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -384,8 +384,7 @@ class BadgeModel: ObservableObject { } enum ChatListBanner { - case badgeExpired - case badgeIssueFailed + case badgeAlert case badgePitch case getStake } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 664eabd9ea..06df9dd3a0 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -12,6 +12,7 @@ import UIKit import Dispatch import BackgroundTasks import SwiftUI +import StoreKit @preconcurrency import SimpleXChat private var chatController: chat_ctrl? @@ -2206,18 +2207,28 @@ func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: enum BadgePurchaseResult { case held(user: UserRef, badgeState: BadgeState?, storePurchases: [OpenStorePurchase]) case credited(user: User, badgeState: BadgeState?) + // listed, and no record here has it: another device's purchase on the same Apple ID + case notRecorded } // log: false because a store receipt is a bearer secret, like a badge code - it is in the command. -func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, _ payment: ServicePayment) async throws -> BadgePurchaseResult { - let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, payment: payment), log: false) +func apiPurchaseBadge(_ userId: Int64, _ echoedInvoiceId: String?, origin: StoreReceiptOrigin, _ payment: ServicePayment) async throws -> BadgePurchaseResult { + let r: ChatResponse2 = try await chatSendCmd(.apiPurchaseBadge(userId: userId, echoedInvoiceId: echoedInvoiceId, origin: origin, payment: payment), log: false) switch r { case let .badgeState(user, badgeState, storePurchases): return .held(user: user, badgeState: badgeState, storePurchases: storePurchases ?? []) case let .badgeRedeemed(user, _, _, badgeState): return .credited(user: user, badgeState: badgeState) + case .storeReceiptNotRecorded: return .notRecorded default: throw r.unexpected } } +// the device store's view of a subscription it charges, which changes what is shown and alerted, and retires its badge once ended +func apiSetBadgeSubscriptionStatus(_ status: BadgeSubscriptionState, _ payment: ServicePayment) async throws { + let r: ChatResponse2 = try await chatSendCmd(.apiSetBadgeSubscriptionStatus(status: status, payment: payment), log: false) + if case .cmdOk = r { return } + throw r.unexpected +} + func apiCreateBadgeInvoice(_ userId: Int64) async throws -> String { let r: ChatResponse2 = try await chatSendCmd(.apiCreateBadgeInvoice(userId: userId)) if case let .badgeInvoice(_, invoiceId) = r { return invoiceId } @@ -2238,12 +2249,33 @@ func redeemErrorText(_ error: Error, purchase: Bool) -> String { return purchase ? NSLocalizedString("This profile already has a badge.", comment: "alert message") : NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .storePurchaseOpen: + return purchase + ? NSLocalizedString("A store purchase is being added to this profile.", comment: "alert message") + : NSLocalizedString("A store purchase is being added to this profile. Redeem the code once it is added, or on another profile.", comment: "alert message") case let .serviceError(code): if let text = badgeServiceErrorText(code) { return text } case let .invalidResponse(message): return String.localizedStringWithFormat(NSLocalizedString("The badge service sent an unexpected response: %@", comment: "alert message"), message) case .unknownKeyIndex, .credentialNotVerified: return NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") } } + if case .subscribedBefore? = error as? BadgeStoreError { + return NSLocalizedString("This Apple ID already has a badge subscription. Only the profile that bought it can continue it.", comment: "alert message") + } + if let e = error as? Product.PurchaseError { + switch e { + case .productUnavailable: return NSLocalizedString("This badge is not on sale.", comment: "alert message") + case .purchaseNotAllowed: return NSLocalizedString("In-app purchases are not allowed on this device.", comment: "alert message") + default: break + } + } + if let e = error as? StoreKitError { + switch e { + case .networkError: return NSLocalizedString("The store could not be reached.", comment: "alert message") + case .notAvailableInStorefront: return NSLocalizedString("This badge is not on sale.", comment: "alert message") + default: break + } + } return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } diff --git a/apps/ios/Shared/Views/Badges/BadgeStore.swift b/apps/ios/Shared/Views/Badges/BadgeStore.swift index 0351884085..558f5343f2 100644 --- a/apps/ios/Shared/Views/Badges/BadgeStore.swift +++ b/apps/ios/Shared/Views/Badges/BadgeStore.swift @@ -9,6 +9,7 @@ import Foundation import Combine import StoreKit +import UIKit import SimpleXChat // TODO [badges] product ids will come from app config and prices from the badge service catalog; @@ -28,16 +29,6 @@ let badgeProductIds: [String] = BadgeLevel.allCases.flatMap { level in BadgePeriod.allCases.map { badgeProductId(level, $0) } } -// the only products sent to the badge service: nothing delivers a subscription yet -let badgeOneTimeProductIds: Set = Set(BadgeLevel.allCases.map { badgeProductId($0, .oneMonth) }) - -// subscriptions are for sale once renewals are delivered -let badgePeriodsForSale: [BadgePeriod] = [.oneMonth] - -// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. -// Apple requires a UUID - it is sent as appAccountToken and echoed back in the signed transaction. -func newBadgeInvoiceId() -> UUID { UUID() } - // the page's app flag rides in the fragment, which never reaches the service let badgePageUrl = "https://badges.simplex.chat/#/tier?app=true" // the dev deployment, for testing store purchases: swap with the line above @@ -62,6 +53,38 @@ enum BadgePrice { } } +struct BadgePriceList { + let oneMonth: BadgePrice + let monthly: BadgePrice + let annual: BadgePrice + let monthlyAmount: Decimal? + let annualAmount: Decimal? + let subscriptionsForSale: Bool + let subscribedBefore: Bool + + func price(_ period: BadgePeriod) -> BadgePrice { + switch period { + case .oneMonth: oneMonth + case .monthly: monthly + case .annual: annual + } + } + + func forSale(_ period: BadgePeriod) -> Bool { + period == .oneMonth || subscriptionsForSale + } + + // percentage the annual subscription saves against 12 monthly payments + var annualSavings: Int? { + guard let monthlyAmount, let annualAmount else { return nil } + let year = monthlyAmount * 12 + guard year > 0, annualAmount < year else { return nil } + let saved = (year - annualAmount) / year * 100 + let percent = Int(NSDecimalNumber(decimal: saved).doubleValue.rounded()) + return percent > 0 ? percent : nil + } +} + struct BadgeStoreReceipt { // the signed token the badge service verifies - never transaction.jsonRepresentation let jws: String @@ -92,6 +115,8 @@ enum BadgeStoreError: Error { case unknownPurchaseResult case noActiveProfile case invalidInvoiceId(String) + case noWindowScene + case subscribedBefore } final class BadgeStore: ObservableObject { @@ -111,6 +136,9 @@ final class BadgeStore: ObservableObject { // set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was // closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again @Published private var reconciledOnce = false + // the original ids of the Apple ID's subscriptions; nil until load() has read them. Apple folds a new subscription + // of an Apple ID that subscribed before into the earlier one, which core resumes only for the profile it retired + @Published private(set) var priorSubscriptions: Set? = nil // the sweep has no buyer waiting and runs at four triggers, so a failure that cannot clear is told once a run private var sweepFailureShown = false private var transactionUpdates: Task? = nil @@ -143,6 +171,27 @@ final class BadgeStore: ObservableObject { return storePurchases.purchases } + func forSale(_ level: BadgeLevel, _ period: BadgePeriod) -> Bool { + switch period { + case .oneMonth: true + case .monthly, .annual: priorSubscriptions?.allSatisfy { $0 == resumableSubscription(level) } ?? false + } + } + + // a subscription of an Apple ID that subscribed before resumes only the active profile's newest purchase, + // once its badge is retired and only at its own level, as the service grants a new charge only of the same tier + private func resumableSubscription(_ level: BadgeLevel) -> String? { + let badgeModel = BadgeModel.shared + guard badgeModel.userId == ChatModel.shared.currentUser?.userId, + let badgeState = badgeModel.badgeState, + !badgeState.shown, + badgeState.badgeType == level.badgeType + else { return nil } + return badgeState.subscription?.transactionRef + } + + var subscribedBefore: Bool { priorSubscriptions?.isEmpty == false } + func price(_ level: BadgeLevel, _ period: BadgePeriod, compact: Bool = true) -> BadgePrice { switch state { case .notLoaded, .loading: return .loading @@ -152,16 +201,16 @@ final class BadgeStore: ObservableObject { } } - // 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 priceList(_ level: BadgeLevel, compact: Bool = true) -> BadgePriceList { + BadgePriceList( + oneMonth: price(level, .oneMonth, compact: compact), + monthly: price(level, .monthly, compact: compact), + annual: price(level, .annual, compact: compact), + monthlyAmount: products[badgeProductId(level, .monthly)]?.price, + annualAmount: products[badgeProductId(level, .annual)]?.price, + subscriptionsForSale: forSale(level, .monthly), + subscribedBefore: subscribedBefore + ) } func load() async { @@ -176,8 +225,10 @@ final class BadgeStore: ObservableObject { 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)") } + let prior = await subscriptionOriginalIds() await MainActor.run { products = byId + priorSubscriptions = prior state = .loaded } } catch let error { @@ -186,17 +237,19 @@ final class BadgeStore: ObservableObject { } } - // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + // A purchase has its core record before the store charges, and every store outcome reaches it. func purchase(_ level: BadgeLevel, _ period: BadgePeriod) async throws -> BadgePurchaseOutcome { let productId = badgeProductId(level, period) guard let product = await MainActor.run(body: { products[productId] }) else { throw BadgeStoreError.productUnavailable(productId: productId) } - // a subscription is never sent to core, so nothing would ever finish it later - guard badgeOneTimeProductIds.contains(productId) else { - let outcome = try await storePurchase(product, newBadgeInvoiceId()) - if case let .purchased(receipt) = outcome { await receipt.transaction.finish() } - return outcome + // another device on this Apple ID may have subscribed since load(), and Apple would fold this purchase into that one + if product.type == .autoRenewable { + let prior = await subscriptionOriginalIds() + await MainActor.run { priorSubscriptions = priorSubscriptions?.union(prior) } + } + guard await MainActor.run(body: { forSale(level, period) }) else { + throw BadgeStoreError.subscribedBefore } guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { throw BadgeStoreError.noActiveProfile @@ -207,8 +260,16 @@ final class BadgeStore: ObservableObject { await loadBadgeStateAsync(userId) do { let outcome = try await storePurchase(product, invoiceId) + if product.type == .autoRenewable { + switch outcome { + case let .purchased(receipt): await MainActor.run { _ = priorSubscriptions?.insert(String(receipt.transaction.originalID)) } + // StoreKit lists no deferred purchase, so subscriptions stay shut for this run + case .pending: await MainActor.run { priorSubscriptions = nil } + case .cancelled: break + } + } if case let .purchased(receipt) = outcome, receipt.signatureVerified { - try await handOver(receipt) + try await handOver(receipt, origin: .purchased) } await MainActor.run { buying = false } return outcome @@ -232,11 +293,11 @@ final class BadgeStore: ObservableObject { // Core holds the receipt and credits it; the transaction stays unfinished until core answers it credited // or refused, as an unfinished transaction is what the store re-delivers if anything is lost on the way. - private func handOver(_ receipt: BadgeStoreReceipt) async throws { + private func handOver(_ receipt: BadgeStoreReceipt, origin: StoreReceiptOrigin) async throws { guard let userId = await MainActor.run(body: { ChatModel.shared.currentUser?.userId }) else { return } // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer do { - switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, .apple(jws: receipt.jws)) { + switch try await apiPurchaseBadge(userId, receipt.echoedInvoiceId, origin: origin, .apple(jws: receipt.jws)) { case let .held(user, badgeState, storePurchases): await MainActor.run { // the answer is the owner's, which may be another profile and a hidden one @@ -254,10 +315,15 @@ final class BadgeStore: ObservableObject { } } await receipt.transaction.finish() + case .notRecorded: + // left unfinished, for the device that bought it, or for Restore to claim it here + break } } catch let error where badgeReceiptRefused(error) { logger.error("BadgeStore.handOver: \(responseError(error))") await receipt.transaction.finish() + // Restore found this purchase, so it says why it was refused rather than that there was nothing to restore + if origin == .restored { throw error } } } @@ -299,10 +365,11 @@ final class BadgeStore: ObservableObject { func presentUnfinished() async { await listenForTransactions() for await verification in Transaction.unfinished { - await reconcile(verification) + await reconcileListed(verification) } await loadCurrentBadgeState() await MainActor.run { reconciledOnce = true } + await reportSubscriptionStates() } // transactions the store settles outside a purchase call, such as an approved Ask to Buy @@ -311,33 +378,133 @@ final class BadgeStore: ObservableObject { if transactionUpdates == nil { transactionUpdates = Task.detached { for await verification in Transaction.updates { - await BadgeStore.shared.reconcile(verification) + await BadgeStore.shared.reconcileListed(verification) } } } } - // an unverified one-time transaction is never sent, and left unfinished rather than lost - private func reconcile(_ verification: VerificationResult) async { - let receipt = storeReceipt(verification) - if !badgeOneTimeProductIds.contains(receipt.productId) { - await receipt.transaction.finish() - } else if receipt.signatureVerified { - // the receipt stays unfinished, so the next sweep hands it over again - do { try await handOver(receipt) } catch let error { - logger.error("BadgeStore.reconcile: \(responseError(error))") - await MainActor.run { - // showAlert presents on the top view controller, which the launch sweep can precede; - // an alert with nowhere to go must not spend the one this run gets - if !sweepFailureShown, getTopViewController() != nil { - sweepFailureShown = true - showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true)) - } + // the store lists an Apple ID's transactions on every device signed into it, so these are handed over as listed + private func reconcileListed(_ verification: VerificationResult) async { + // the receipt stays unfinished, so the next sweep hands it over again + do { try await reconcile(verification, origin: .listed) } catch let error { + logger.error("BadgeStore.reconcile: \(responseError(error))") + await MainActor.run { + // showAlert presents on the top view controller, which the launch sweep can precede; + // an alert with nowhere to go must not spend the one this run gets + if !sweepFailureShown, getTopViewController() != nil { + sweepFailureShown = true + showAlert(NSLocalizedString("Purchase error", comment: "alert title"), message: redeemErrorText(error, purchase: true)) } } } } + // an unverified transaction is never sent, and left unfinished rather than lost + private func reconcile(_ verification: VerificationResult, origin: StoreReceiptOrigin) async throws { + let receipt = storeReceipt(verification) + let t = receipt.transaction + if t.productType == .autoRenewable { await MainActor.run { _ = priorSubscriptions?.insert(String(t.originalID)) } } + if t.productType == .autoRenewable && t.revocationDate != nil { + // the service refuses a revoked charge as invalid, and that would refuse the whole subscription's record + await t.finish() + } else if !badgeProductIds.contains(receipt.productId) { + await t.finish() + } else if receipt.signatureVerified { + try await handOver(receipt, origin: origin) + } + } + + // The service asks the store only once the paid period ends, so a cancellation or a failed charge before + // then is seen only here. Sent with the latest transaction, which names the subscription as its first one did. + func reportSubscriptionStates() async { + guard !(await subscriptionOriginalIds()).isEmpty else { return } + await load() + guard let subscription = await MainActor.run(body: { products.values.first { $0.type == .autoRenewable }?.subscription }) else { return } + do { + for status in try await subscription.status { + guard case let .verified(renewalInfo) = status.renewalInfo, case .verified = status.transaction else { continue } + let state: BadgeSubscriptionState? = switch status.state { + case .subscribed: renewalInfo.willAutoRenew ? .renewing : .cancelled + case .inBillingRetryPeriod, .inGracePeriod: .billingRetry + case .expired, .revoked: .ended + default: nil + } + if let state { + try await apiSetBadgeSubscriptionStatus(state, .apple(jws: status.transaction.jwsRepresentation)) + } + } + } catch let error { + logger.error("BadgeStore.reportSubscriptionStates: \(responseError(error))") + } + } + + func manageSubscriptions() async { + let scene = await MainActor.run { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.windows.contains(where: \.isKeyWindow) } + } + do { + guard let scene else { throw BadgeStoreError.noWindowScene } + try await AppStore.showManageSubscriptions(in: scene) + } catch let error { + logger.error("BadgeStore.manageSubscriptions: \(String(describing: error))") + if let url = URL(string: "https://apps.apple.com/account/subscriptions") { + await MainActor.run { UIApplication.shared.open(url) } + } + } + await reportSubscriptionStates() + await loadCurrentBadgeState() + } + + // Hands over as restored whatever the store lists, so this profile claims it, also from another install or profile. + // Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards. + func restorePurchases() async throws -> Bool { + try await AppStore.sync() + var handOverError: Error? = nil + var handedOver: Set = [] + for transactions in [Transaction.currentEntitlements, Transaction.unfinished] { + for await verification in transactions { + // a repeat would be refused once the first hand-over's badge is shown + guard handedOver.insert(verification.unsafePayloadValue.id).inserted else { continue } + do { try await reconcile(verification, origin: .restored) } catch let error { + logger.error("BadgeStore.restorePurchases: \(responseError(error))") + if handOverError == nil { handOverError = error } + } + } + } + await reportSubscriptionStates() + await loadCurrentBadgeState() + let restored: Bool = await MainActor.run { + let userId = ChatModel.shared.currentUser?.userId + let badgeShown = BadgeModel.shared.userId == userId && BadgeModel.shared.badgeState?.shown == true + return badgeShown || purchaseState(userId) != nil + } + if !restored, let handOverError { throw handOverError } + return restored + } + + func subscriptionPrice(_ badgeType: BadgeType, planMonths: Int) -> String? { + let period: BadgePeriod? = switch planMonths { + case 1: .monthly + case 12: .annual + default: nil + } + guard let period, let level = BadgeLevel.allCases.first(where: { $0.badgeType == badgeType }) else { return nil } + return products[badgeProductId(level, period)]?.displayPrice + } + + // an unverified transaction counts too: Apple, not this check, decides what a new subscription folds into + private func subscriptionOriginalIds() async -> Set { + var ids: Set = [] + for await verification in Transaction.all { + let t = verification.unsafePayloadValue + if t.productType == .autoRenewable { ids.insert(String(t.originalID)) } + } + return ids + } + @MainActor private func startLoading() -> Bool { switch state { @@ -378,8 +545,11 @@ private func storeReceipt(_ verification: VerificationResult) -> Ba // the codes core refuses a receipt with for good, after which the store may stop re-delivering it; // a sweep that hands over a receipt refused while the app was not running learns it only from this answer private func badgeReceiptRefused(_ error: Error) -> Bool { - if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { - return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase - } - return false + let code = badgeServiceErrorCode(error) + return code == .receiptInvalid || code == .receiptUsed || code == .testPurchase || code == .transferLimited +} + +private func badgeServiceErrorCode(_ error: Error) -> BadgeServiceErrorCode? { + if case let .error(.badgeRedeemError(.serviceError(code))) = error as? ChatError { return code } + return nil } diff --git a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift index 2c0dd851ed..3c3ee66318 100644 --- a/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesCheckOrderView.swift @@ -7,18 +7,28 @@ // import SwiftUI +import StoreKit import SimpleXChat struct BadgesCheckOrderView: View { @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatModel: ChatModel @ObservedObject private var store = BadgeStore.shared + // store.forSale reads the active profile's badge + @ObservedObject private var badgeModel = BadgeModel.shared let level: BadgeLevel let period: BadgePeriod + var startDate = Date() + var priceList: BadgePriceList? = nil @State private var purchasing = false + @State private var privacyPolicyActive = false // presented from this view, not AlertManager: its host is behind the sheet these views open in @State private var alert: SomeAlert? + private var prices: BadgePriceList { + priceList ?? store.priceList(level, compact: false) + } + var body: some View { GeometryReader { g in ScrollView { @@ -36,7 +46,7 @@ struct BadgesCheckOrderView: View { Divider() .padding(.top, 8) .padding(.bottom, 6) - orderRow("Total", period.priceText(store.price(level, period, compact: false))) + orderRow("Total", period.priceText(prices.price(period))) } .padding(.horizontal, 24) .padding(.vertical, 12) @@ -44,12 +54,45 @@ struct BadgesCheckOrderView: View { .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.top, 20) + if case let .price(p) = prices.price(period) { + switch period { + case .monthly: footnote("Payment is charged to your Apple ID when you confirm the purchase. The subscription renews automatically at \(p) per month unless you cancel it at least 24 hours before the end of the current period. You can manage and cancel it in your App Store account settings.") + case .annual: footnote("Payment is charged to your Apple ID when you confirm the purchase. The subscription renews automatically at \(p) per year unless you cancel it at least 24 hours before the end of the current period. You can manage and cancel it in your App Store account settings.") + case .oneMonth: EmptyView() + } + } + + if !prices.forSale(period) && prices.subscribedBefore { + footnote("This Apple ID already has a badge subscription. Only the profile that bought it can continue it.") + } + + if !AppStore.canMakePayments { + footnote("In-app purchases are not allowed on this device.") + } + Spacer(minLength: 20) VStack(spacing: 10) { payButton() .padding(.vertical, 10) - BadgeBillingFooter(period: period) + BadgeBillingFooter(period: period, startDate: startDate) + HStack(spacing: 20) { + Link("Terms of use", destination: appleStandardEULAURL) + ZStack { + Button("Privacy policy") { privacyPolicyActive = true } + NavigationLink(isActive: $privacyPolicyActive) { + ConditionsTextView() + .padding() + .navigationTitle("Privacy policy") + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + .font(.footnote) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -65,6 +108,14 @@ struct BadgesCheckOrderView: View { .alert(item: $alert) { $0.alert } } + private func footnote(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + private func orderRow(_ title: LocalizedStringKey, _ value: Text) -> some View { HStack { Text(title) @@ -78,8 +129,8 @@ struct BadgesCheckOrderView: View { } private func payButton() -> some View { - let price = store.price(level, period, compact: false) - let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) + let price = prices.price(period) + let disabled = !price.canPurchase || purchasing || !store.canBuy(chatModel.currentUser?.userId) || !prices.forSale(period) || !AppStore.canMakePayments return Button { purchase() } label: { @@ -109,6 +160,8 @@ struct BadgesCheckOrderView: View { logger.error("BadgesCheckOrderView.purchase: \(String(describing: error))") await MainActor.run { purchasing = false + // thrown when the purchase sheet or its sign-in prompt is dismissed, which needs no alert + if case .userCancelled? = error as? StoreKitError { return } alert = SomeAlert( alert: Alert( title: Text("Purchase error"), @@ -122,10 +175,54 @@ struct BadgesCheckOrderView: View { } } -struct BadgesCheckOrderView_Previews: PreviewProvider { - static var previews: some View { - NavigationView { - BadgesCheckOrderView(level: .supporter, period: .oneMonth) +// Apple's standard licence agreement, which an app selling subscriptions links as its terms of use +let appleStandardEULAURL = URL(string: "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/")! + +struct RestorePurchasesButton: View { + @State private var restoring = false + + var body: some View { + Button("Restore purchases") { + restoring = true + Task { + await restore() + await MainActor.run { restoring = false } + } + } + .font(.footnote) + .disabled(restoring) + } + + private func restore() async { + do { + let found = try await BadgeStore.shared.restorePurchases() + if !found { + await MainActor.run { + showAlert( + NSLocalizedString("Nothing to restore", comment: "alert title"), + message: NSLocalizedString("No App Store purchase waiting to be added was found.", comment: "alert message") + ) + } + } + } catch let error { + logger.error("RestorePurchasesButton: \(String(describing: error))") + if case .userCancelled? = error as? StoreKitError { return } + await MainActor.run { + showAlert(NSLocalizedString("Restore failed", comment: "alert title"), message: redeemErrorText(error, purchase: true)) + } + } + } +} + +struct BadgesCheckOrderView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesCheckOrderView( + level: .supporter, + period: .oneMonth, + startDate: previewDate, + priceList: BadgePriceList(oneMonth: .price("$7.00"), monthly: .price("$7.00"), annual: .price("$42.00"), monthlyAmount: 7, annualAmount: 42, subscriptionsForSale: true, subscribedBefore: false) + ) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift index 2377a79a3b..fb3dbb077f 100644 --- a/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesHowLongView.swift @@ -71,10 +71,23 @@ enum BadgePeriod: String, CaseIterable, Identifiable { struct BadgesHowLongView: View { @EnvironmentObject var theme: AppTheme @ObservedObject private var store = BadgeStore.shared + // store.forSale reads the active profile's badge + @ObservedObject private var badgeModel = BadgeModel.shared let level: BadgeLevel - @State private var selectedPeriod: BadgePeriod = badgePeriodsForSale.contains(.monthly) ? .monthly : .oneMonth + var startDate = Date() + var priceList: BadgePriceList? = nil + @State private var chosenPeriod: BadgePeriod? = nil @State private var continueActive = false + private var prices: BadgePriceList { + priceList ?? store.priceList(level) + } + + // monthly once subscriptions turn out to be for sale, unless a period was chosen before that + private var selectedPeriod: BadgePeriod { + chosenPeriod ?? (prices.forSale(.monthly) ? .monthly : .oneMonth) + } + var body: some View { GeometryReader { g in ScrollView { @@ -104,12 +117,20 @@ struct BadgesHowLongView: View { } .fixedSize(horizontal: false, vertical: true) + if !prices.forSale(.monthly) && prices.subscribedBefore { + Text("This Apple ID already has a badge subscription. Only the profile that bought it can continue it.") + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 20) VStack(spacing: 10) { continueButton() .padding(.vertical, 10) - BadgeBillingFooter(period: selectedPeriod) + BadgeBillingFooter(period: selectedPeriod, startDate: startDate) } .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } @@ -126,9 +147,9 @@ struct BadgesHowLongView: View { private func periodCard(_ period: BadgePeriod) -> some View { let isSelected = period == selectedPeriod - let forSale = badgePeriodsForSale.contains(period) + let forSale = prices.forSale(period) return Button { - selectedPeriod = period + chosenPeriod = period } label: { VStack(spacing: 12) { Image(systemName: period.icon) @@ -139,7 +160,7 @@ struct BadgesHowLongView: View { .opacity(forSale ? 1 : 0.4) Text(period.label) .font(.subheadline) - period.priceText(store.price(level, period)) + period.priceText(prices.price(period)) .font(.headline) if let percent = savingsPercent(period) { Text("\(percent)% off") @@ -164,12 +185,13 @@ struct BadgesHowLongView: View { } private func savingsPercent(_ period: BadgePeriod) -> Int? { - period == .annual ? store.annualSavings(level) : nil + period == .annual ? prices.annualSavings : nil } private func continueButton() -> some View { ZStack { Button { + chosenPeriod = selectedPeriod continueActive = true } label: { Text("Continue") @@ -191,6 +213,7 @@ struct BadgesHowLongView: View { struct BadgeBillingFooter: View { @EnvironmentObject var theme: AppTheme let period: BadgePeriod + let startDate: Date var body: some View { Text(billingFooter) @@ -204,7 +227,7 @@ struct BadgeBillingFooter: View { private var billingFooter: LocalizedStringKey { // TODO [badges] from now only because a purchase is refused while a badge is held (refuseWhileBadgeHeld); // a top-up must count from the end of the existing balance - let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: Date()) ?? Date() + let endDate = Calendar.current.date(byAdding: .month, value: period.months, to: startDate) ?? startDate let date = DateFormatter.localizedString(from: endDate, dateStyle: .long, timeStyle: .none) switch period { case .monthly, .annual: return "Renews on \(date). Cancel anytime." @@ -216,7 +239,11 @@ struct BadgeBillingFooter: View { struct BadgesHowLongView_Previews: PreviewProvider { static var previews: some View { NavigationView { - BadgesHowLongView(level: .supporter) + BadgesHowLongView( + level: .supporter, + startDate: previewDate, + priceList: BadgePriceList(oneMonth: .price("$7"), monthly: .price("$7"), annual: .price("$42"), monthlyAmount: 7, annualAmount: 42, subscriptionsForSale: true, subscribedBefore: false) + ) } } } diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift index 97e91bfb69..543acd235a 100644 --- a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -45,8 +45,9 @@ struct BadgesSupportSimplexView: View { Spacer(minLength: 0) - // In-app purchase is not released: the badge is chosen on the site, and the code - // redeemed here. Revert this commit to restore the block below. + // Store purchases are not released on iOS: TestFlight buys in the sandbox, which the + // service refuses for good. The badge is chosen on the site, and the code redeemed here. + // Revert this commit to restore the block below. // VStack(spacing: 10) { // chooseLevelButton() // .padding(.vertical, 10) @@ -58,6 +59,14 @@ struct BadgesSupportSimplexView: View { // } // } // .frame(height: 22) + // RestorePurchasesButton() + // .frame(height: 22) + // .padding(.top, 10) + // Text("If you were charged but have no badge, this adds it. A subscription bought with this App Store account moves to this profile, and the device or profile that had it loses its badge at its next renewal.") + // .font(.footnote) + // .foregroundColor(theme.colors.secondary) + // .multilineTextAlignment(.center) + // .fixedSize(horizontal: false, vertical: true) // } VStack(spacing: 10) { chooseBadgeOnSiteButton() @@ -141,7 +150,7 @@ struct BadgesSupportSimplexView: View { } } - // In-app purchase is not released: delete with the commented block above to restore it. + // delete with the commented block above to restore store purchases private func chooseBadgeOnSiteButton() -> some View { Button { UIApplication.shared.open(URL(string: badgePageUrl)!) diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 60c6a0ab77..3b744951cb 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,6 +14,7 @@ struct BadgesYourBadgeView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) private var dismiss @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false + @ObservedObject private var store = BadgeStore.shared let badgeState: BadgeState var showsAsSheet: Bool = false @@ -34,14 +35,42 @@ struct BadgesYourBadgeView: View { 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) + if let subscription = badgeState.subscription { + let ended = badgeState.paidThrough <= Date.now + let date = badgeState.paidThroughText + switch subscription.status { + case .billingRetry: + subscriptionSection( + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Payment problem") + }, + footer: "The App Store could not charge your payment method and is retrying. Update it to keep your badge. Until the payment goes through, you cannot buy or redeem another badge." + ) + case .cancelled where !ended: + subscriptionSection(Text("Ends \(date)"), footer: "Your subscription is cancelled. The badge stays until \(date).") + case .ended, .cancelled: + subscriptionSection(Text("Ended"), footer: "Your subscription has ended. The badge will be removed from your profile.") + case .renewing, .unknown, nil: + if ended { + subscriptionSection(Text("Renewing"), footer: "Your badge is renewed once the App Store charges your subscription. Until then you cannot buy or redeem another badge.") + } else if let price = store.subscriptionPrice(badgeState.badgeType, planMonths: subscription.planMonths) { + subscriptionSection(Text("Renews \(date) · \(price)"), footer: "Your subscription renews automatically. You can cancel it in the App Store.") + } else { + subscriptionSection(Text("Renews \(date)"), footer: "Your subscription renews automatically. You can cancel it in the App Store.") + } + } + } else { + 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) + } } Section { NavigationLink { @@ -110,6 +139,23 @@ struct BadgesYourBadgeView: View { .modifier(ThemedBackground(grouped: true)) } + private func subscriptionSection(_ status: some View, footer: LocalizedStringKey) -> some View { + Section { + status + settingsRow("creditcard", color: theme.colors.secondary) { + Button("Manage subscription") { + Task { await BadgeStore.shared.manageSubscriptions() } + } + } + } header: { + Text("Subscription") + .foregroundColor(theme.colors.secondary) + } footer: { + Text(footer) + .foregroundColor(theme.colors.secondary) + } + } + private func badgeTimestamp(_ date: Date) -> String { DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) } diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index a2e65dcf71..c74ee673d2 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -533,9 +533,9 @@ struct ActiveCallOverlay: View { struct ActiveCallOverlay_Previews: PreviewProvider { static var previews: some View { Group{ - ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil))) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: "00000000-0000-0000-0000-000000000001", callState: .offerSent, initialCallType: .video), client: WebRTCClient({ _ in }, Binding.constant(nil))) .background(.black) - ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: UUID().uuidString.lowercased(), callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil))) + ActiveCallOverlay(call: Call(direction: .incoming, contact: Contact.sampleData, callUUID: "00000000-0000-0000-0000-000000000002", callState: .offerSent, initialCallType: .audio), client: WebRTCClient({ _ in }, Binding.constant(nil))) .background(.black) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 5521470d07..a1dee2c398 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -111,7 +111,7 @@ struct CIChatFeatureView_Previews: PreviewProvider { CIChatFeatureView( chat: Chat.sampleData, im: ItemsModel.shared, - chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary) + chatItem: previewDated(ChatItem.getChatFeatureSample(.fullDelete, enabled)), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary) ).environment(\.revealed, true) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index dcd6ea579c..47afc76b7f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -78,7 +78,7 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider { let content = CIContent.rcvChatPreference(feature: .timedMessages, allowed: .yes, param: 30) let chatItem = ChatItem( chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, content.text, .rcvRead), + meta: CIMeta.getSample(1, previewDate, content.text, .rcvRead), content: content, quotedItem: nil, file: nil diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 5c65a310b5..eff3b84b42 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -316,29 +316,37 @@ struct CIFileView_Previews: PreviewProvider { let im = ItemsModel.shared let sentFile: ChatItem = ChatItem( chatDir: .directSnd, - meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), + meta: CIMeta.getSample(1, previewDate, "", .sndSent(sndProgress: .complete), itemEdited: true), content: .sndMsgContent(msgContent: .file("")), quotedItem: nil, file: CIFile.getSample(fileStatus: .sndComplete) ) let fileChatItemWtFile = ChatItem( chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "", .rcvRead), + meta: CIMeta.getSample(1, previewDate, "", .rcvRead), content: .rcvMsgContent(msgContent: .file("")), quotedItem: nil, file: nil ) Group { - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(sentFile, tailVisible: true)) + let ci1 = previewDated(ChatItem.getFileMsgContentSample()) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = previewDated(ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = previewDated(ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = previewDated(ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = previewDated(ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci5, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci5, tailVisible: true)) + let ci6 = previewDated(ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci6, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci6, tailVisible: true)) + let ci7 = previewDated(ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci7, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci7, tailVisible: true)) + let ci8 = previewDated(ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci8, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci8, tailVisible: true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: fileChatItemWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(fileChatItemWtFile, tailVisible: true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index efec44e943..41a4d9c4e6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -153,8 +153,9 @@ struct CIGroupInvitationView: View { struct CIGroupInvitationView_Previews: PreviewProvider { static var previews: some View { Group { - CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin) - CIGroupInvitationView(chat: Chat.sampleData, chatItem: ChatItem.getGroupInvitationSample(), groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin) + let ci = previewDated(ChatItem.getGroupInvitationSample()) + CIGroupInvitationView(chat: Chat.sampleData, chatItem: ci, groupInvitation: CIGroupInvitation.getSample(groupProfile: GroupProfile(displayName: "team", fullName: "team")), memberRole: .admin).modifier(ChatItemClipped(ci, tailVisible: true)) + CIGroupInvitationView(chat: Chat.sampleData, chatItem: ci, groupInvitation: CIGroupInvitation.getSample(status: .accepted), memberRole: .admin).modifier(ChatItemClipped(ci, tailVisible: true)) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index 4719c3dcdc..c68b55288b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -72,7 +72,7 @@ struct CIMemberCreatedContactView_Previews: PreviewProvider { let content = CIContent.rcvGroupEvent(rcvGroupEvent: .memberCreatedContact) let chatItem = ChatItem( chatDir: .groupRcv(groupMember: GroupMember.sampleData), - meta: CIMeta.getSample(1, .now, content.text, .rcvRead), + meta: CIMeta.getSample(1, previewDate, content.text, .rcvRead), content: content, quotedItem: nil, file: nil diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 2984c3a286..af7634672b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -193,13 +193,13 @@ struct CIMetaView_Previews: PreviewProvider { static let metaColor = Color.secondary static var previews: some View { Group { - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor) - CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .complete)), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .partial)), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true), metaColor: metaColor) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(1, previewDate), metaColor: metaColor) } .previewLayout(.fixed(width: 360, height: 100)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 6c89a5f788..d79f3cca0c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -445,30 +445,34 @@ struct CIVoiceView_Previews: PreviewProvider { let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, - meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), + meta: CIMeta.getSample(1, previewDate, "", .sndSent(sndProgress: .complete), itemEdited: true), content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)), quotedItem: nil, file: CIFile.getSample(fileStatus: .sndComplete) ) let voiceMessageWtFile = ChatItem( chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "", .rcvRead), + meta: CIMeta.getSample(1, previewDate, "", .rcvRead), content: .rcvMsgContent(msgContent: .voice(text: "", duration: 30)), quotedItem: nil, file: nil ) Group { + let ci1 = previewDated(ChatItem.getVoiceMsgContentSample()) CIVoiceView( chat: Chat.sampleData, - chatItem: ChatItem.getVoiceMsgContentSample(), + chatItem: ci1, recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), duration: 30, allowMenu: Binding.constant(true) ) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)) + .modifier(ChatItemClipped(ci1, tailVisible: true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)).modifier(ChatItemClipped(sentVoiceMessage, tailVisible: true)) + let ci2 = previewDated(ChatItem.getVoiceMsgContentSample()) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = previewDated(ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)).modifier(ChatItemClipped(ci3, tailVisible: true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWtFile, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: .constant(true)).modifier(ChatItemClipped(voiceMessageWtFile, tailVisible: true)) } .previewLayout(.fixed(width: 360, height: 360)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index fb5d36ab12..93e32d4fc8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -34,8 +34,10 @@ struct DeletedItemView: View { struct DeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) - DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) + let ci1 = ChatItem.getDeletedContentSample(1, previewDate) + DeletedItemView(chat: Chat.sampleData, chatItem: ci1).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData), previewDate) + DeletedItemView(chat: Chat.sampleData, chatItem: ci2).modifier(ChatItemClipped(ci2, tailVisible: true)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 04f36c97a4..bbe0e99a2b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -36,8 +36,8 @@ func emojiText(_ text: String) -> Text { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) - EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, previewDate, "🙂", .sndSent(sndProgress: .complete))) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, previewDate, "👍")) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 123f7289bb..3e8eecc251 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -82,24 +82,27 @@ struct FramedCIVoiceView_Previews: PreviewProvider { let im = ItemsModel.shared let sentVoiceMessage: ChatItem = ChatItem( chatDir: .directSnd, - meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), + meta: CIMeta.getSample(1, previewDate, "", .sndSent(sndProgress: .complete), itemEdited: true), content: .sndMsgContent(msgContent: .voice(text: "Hello there", duration: 30)), quotedItem: nil, file: CIFile.getSample(fileStatus: .sndComplete) ) let voiceMessageWithQuote: ChatItem = ChatItem( chatDir: .directSnd, - meta: CIMeta.getSample(1, .now, "", .sndSent(sndProgress: .complete), itemEdited: true), + meta: CIMeta.getSample(1, previewDate, "", .sndSent(sndProgress: .complete), itemEdited: true), content: .sndMsgContent(msgContent: .voice(text: "", duration: 30)), - quotedItem: CIQuote.getSample(1, .now, "Hi", chatDir: .directRcv), + quotedItem: CIQuote.getSample(1, previewDate, "Hi", chatDir: .directRcv), file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getVoiceMsgContentSample(text: "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."), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: sentVoiceMessage, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(sentVoiceMessage, tailVisible: true)) + let ci1 = previewDated(ChatItem.getVoiceMsgContentSample(text: "Hello there")) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = previewDated(ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10))) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = previewDated(ChatItem.getVoiceMsgContentSample(text: "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.")) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci3, tailVisible: true)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: voiceMessageWithQuote, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(voiceMessageWithQuote, tailVisible: true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 360)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index b6bac78d46..b4db33f7cc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -453,14 +453,22 @@ struct FramedItemView_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group{ - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + let ci1 = ChatItem.getSample(1, .directSnd, previewDate, "hello") + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello", quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directSnd)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directRcv)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = ChatItem.getSample(2, .directSnd, previewDate, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "Hello too", chatDir: .directRcv)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this covers -") + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci5, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci5, tailVisible: true)) + let ci6 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this text has the time on the same line ") + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci6, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci6, tailVisible: true)) + let ci7 = ChatItem.getSample(2, .directRcv, previewDate, "https://simplex.chat") + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci7, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci7, tailVisible: true)) + let ci8 = ChatItem.getSample(2, .directRcv, previewDate, "chaT@simplex.chat") + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci8, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci8, tailVisible: true)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -470,16 +478,26 @@ struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + let ci1 = ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello", quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directSnd), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directRcv), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = ChatItem.getSample(2, .directSnd, previewDate, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "Hello too", chatDir: .directRcv), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this covers -", .rcvRead, itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci5, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci5, tailVisible: true)) + let ci6 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci6, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci6, tailVisible: true)) + let ci7 = ChatItem.getSample(2, .directRcv, previewDate, "https://simplex.chat", .rcvRead, itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci7, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci7, tailVisible: true)) + let ci8 = ChatItem.getSample(2, .directRcv, previewDate, "chaT@simplex.chat", .rcvRead, itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci8, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci8, tailVisible: true)) + let ci9 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello", quotedItem: CIQuote.getSample(1, previewDate, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci9, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci9, tailVisible: true)) + let ci10 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello there this is a long text", quotedItem: CIQuote.getSample(1, previewDate, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci10, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci10, tailVisible: true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) @@ -490,16 +508,26 @@ struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group { - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) - FramedItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)) + let ci1 = ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello", quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = ChatItem.getSample(2, .directSnd, previewDate, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = ChatItem.getSample(2, .directSnd, previewDate, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, previewDate, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci5, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci5, tailVisible: true)) + let ci6 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci6, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci6, tailVisible: true)) + let ci7 = ChatItem.getSample(2, .directRcv, previewDate, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci7, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci7, tailVisible: true)) + let ci8 = ChatItem.getSample(2, .directRcv, previewDate, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci8, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci8, tailVisible: true)) + let ci9 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello", quotedItem: CIQuote.getSample(1, previewDate, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci9, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci9, tailVisible: true)) + let ci10 = ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), previewDate, "hello there this is a long text", quotedItem: CIQuote.getSample(1, previewDate, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: previewDate)) + FramedItemView(chat: Chat.sampleData, im: im, chatItem: ci10, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil), allowMenu: Binding.constant(true)).modifier(ChatItemClipped(ci10, tailVisible: true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 200)) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index d831333c20..2f0492e1d9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -98,6 +98,7 @@ struct RcvMsgErrorItemView: View { struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { - IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) + let ci = previewDated(ChatItem.getIntegrityErrorSample()) + IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ci).modifier(ChatItemClipped(ci, tailVisible: true)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 953f4e8c82..08a648083a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -86,11 +86,12 @@ struct MarkedDeletedItemView: View { struct MarkedDeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { + let ci = ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: previewDate)) MarkedDeletedItemView( chat: Chat.sampleData, im: ItemsModel.shared, - chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)) - ).environment(\.revealed, true) + chatItem: ci + ).environment(\.revealed, true).modifier(ChatItemClipped(ci, tailVisible: true)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 10bd784b7e..e91ee7f32f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -1020,7 +1020,7 @@ func stripFormattedTextLink(_ ft: [FormattedText]?, _ link: String) -> [Formatte struct MsgContentView_Previews: PreviewProvider { static var previews: some View { - let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") + let chatItem = ChatItem.getSample(1, .directSnd, previewDate, "hello") return MsgContentView( chat: Chat.sampleData, text: chatItem.text, diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 92bab973c9..10c3d85dd7 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -129,11 +129,13 @@ struct ChatItemForwardingView: View { } } -#Preview { - ChatItemForwardingView( - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")], - fromChatInfo: .direct(contact: Contact.sampleData), - composeState: Binding.constant(ComposeState(message: "hello")) - ).environmentObject(CurrentColors.toAppTheme()) +struct ChatItemForwardingView_Previews: PreviewProvider { + static var previews: some View { + ChatItemForwardingView( + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello")], + fromChatInfo: .direct(contact: Contact.sampleData), + composeState: Binding.constant(ComposeState(message: "hello")) + ).environmentObject(CurrentColors.toAppTheme()) + } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 3fff0e34d1..54e0726554 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -618,6 +618,6 @@ func localTimestamp(_ date: Date) -> String { struct ChatItemInfoView_Previews: PreviewProvider { static var previews: some View { - ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, .now, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) + ChatItemInfoView(ci: ChatItem.getSample(1, .directSnd, previewDate, "hello"), userMemberId: Chat.sampleData.chatInfo.groupInfo?.membership.memberId, chatItemInfo: Binding.constant(nil)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 1839651daa..c7ba44b8f0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -286,19 +286,36 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = fa chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor) } +func previewDated(_ chatItem: ChatItem) -> ChatItem { + var ci = chatItem + ci.meta.itemTs = previewDate + ci.meta.createdAt = previewDate + ci.meta.updatedAt = previewDate + return ci +} + struct ChatItemView_Previews: PreviewProvider { static var previews: some View { let im = ItemsModel.shared Group{ - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getDeletedContentSample(), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) - ChatItemView(chat: Chat.sampleData, im: im, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true) + let ci1 = ChatItem.getSample(1, .directSnd, previewDate, "hello") + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci1, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem.getSample(2, .directRcv, previewDate, "hello there too") + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = ChatItem.getSample(1, .directSnd, previewDate, "🙂") + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = ChatItem.getSample(2, .directRcv, previewDate, "🙂🙂🙂🙂🙂") + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = ChatItem.getSample(2, .directRcv, previewDate, "🙂🙂🙂🙂🙂🙂") + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci5, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci5, tailVisible: true)) + let ci6 = ChatItem.getDeletedContentSample(1, previewDate) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci6, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci6, tailVisible: true)) + let ci7 = ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: previewDate)) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci7, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).modifier(ChatItemClipped(ci7, tailVisible: true)) + let ci8 = ChatItem.getSample(1, .directSnd, previewDate, "🙂", .sndSent(sndProgress: .complete), itemLive: true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci8, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true).modifier(ChatItemClipped(ci8, tailVisible: true)) + let ci9 = ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemLive: true) + ChatItemView(chat: Chat.sampleData, im: im, chatItem: ci9, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil)).environment(\.revealed, true).modifier(ChatItemClipped(ci9, tailVisible: true)) } .environment(\.revealed, false) .previewLayout(.fixed(width: 360, height: 70)) @@ -311,70 +328,80 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { let im = ItemsModel.shared let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ - ChatItemView( - chat: Chat.sampleData, - im: im, - chatItem: ChatItem( - chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), - content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)), - quotedItem: nil, - file: nil - ), - scrollToItem: { _ in }, - scrollToItemId: Binding.constant(nil) + let ci1 = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, previewDate, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)), + content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)), + quotedItem: nil, + file: nil ) ChatItemView( chat: Chat.sampleData, im: im, - chatItem: ChatItem( - chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), - content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2), - quotedItem: nil, - file: nil - ), + chatItem: ci1, + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) + ) + .modifier(ChatItemClipped(ci1, tailVisible: true)) + let ci2 = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, previewDate, "1 skipped message", .rcvRead), + content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2), + quotedItem: nil, + file: nil + ) + ChatItemView( + chat: Chat.sampleData, + im: im, + chatItem: ci2, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil) ) - ChatItemView( - chat: Chat.sampleData, - im: im, - chatItem: ChatItem( - chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), - content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin), - quotedItem: nil, - file: nil - ), - scrollToItem: { _ in }, - scrollToItemId: Binding.constant(nil) + .modifier(ChatItemClipped(ci2, tailVisible: true)) + let ci3 = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, previewDate, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)), + content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin), + quotedItem: nil, + file: nil ) ChatItemView( chat: Chat.sampleData, im: im, - chatItem: ChatItem( - chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), - content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), - quotedItem: nil, - file: nil - ), + chatItem: ci3, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil) ) + .modifier(ChatItemClipped(ci3, tailVisible: true)) + let ci4 = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, previewDate, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)), + content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), + quotedItem: nil, + file: nil + ) ChatItemView( chat: Chat.sampleData, im: im, - chatItem: ChatItem( - chatDir: .directRcv, - meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), - content: ciFeatureContent, - quotedItem: nil, - file: nil - ), + chatItem: ci4, scrollToItem: { _ in }, scrollToItemId: Binding.constant(nil) ) + .modifier(ChatItemClipped(ci4, tailVisible: true)) + let ci5 = ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, previewDate, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: previewDate)), + content: ciFeatureContent, + quotedItem: nil, + file: nil + ) + ChatItemView( + chat: Chat.sampleData, + im: im, + chatItem: ci5, + scrollToItem: { _ in }, + scrollToItemId: Binding.constant(nil) + ) + .modifier(ChatItemClipped(ci5, tailVisible: true)) } .environment(\.revealed, true) .previewLayout(.fixed(width: 360, height: 70)) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2a903fcd91..1ab265ae30 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -3329,15 +3329,15 @@ struct ChatView_Previews: PreviewProvider { chatModel.chatId = "@1" let im = ItemsModel.shared im.reversedChatItems = [ - ChatItem.getSample(1, .directSnd, .now, "hello"), - ChatItem.getSample(2, .directRcv, .now, "hi"), - ChatItem.getSample(3, .directRcv, .now, "hi there"), - ChatItem.getDeletedContentSample(4), - ChatItem.getSample(5, .directRcv, .now, "hello again"), - ChatItem.getSample(6, .directSnd, .now, "hi there!!!"), - ChatItem.getSample(7, .directSnd, .now, "how are you?"), - ChatItem.getSample(8, .directSnd, .now, "👍👍👍👍"), - ChatItem.getSample(9, .directSnd, .now, "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.") + ChatItem.getSample(1, .directSnd, previewDate, "hello"), + ChatItem.getSample(2, .directRcv, previewDate, "hi"), + ChatItem.getSample(3, .directRcv, previewDate, "hi there"), + ChatItem.getDeletedContentSample(4, previewDate), + ChatItem.getSample(5, .directRcv, previewDate, "hello again"), + ChatItem.getSample(6, .directSnd, previewDate, "hi there!!!"), + ChatItem.getSample(7, .directSnd, previewDate, "how are you?"), + ChatItem.getSample(8, .directSnd, previewDate, "👍👍👍👍"), + ChatItem.getSample(9, .directSnd, previewDate, "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.") ] @State var showChatInfo = false return ChatView( diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift index 82c89cd43d..8637321ad2 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift @@ -90,8 +90,10 @@ struct ContextContactRequestActionsView: View { } } -#Preview { - ContextContactRequestActionsView( - contactRequestId: 1 - ) +struct ContextContactRequestActionsView_Previews: PreviewProvider { + static var previews: some View { + ContextContactRequestActionsView( + contactRequestId: 1 + ) + } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 13614692cb..f7f8d2982e 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -98,7 +98,7 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { - let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") + let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, previewDate, "hello") return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift index 9a73b2b5d4..c9fa58471d 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextMemberContactActionsView.swift @@ -102,9 +102,11 @@ func acceptMemberContactRequest(_ contact: Contact, inProgress: Binding? = } } -#Preview { - ContextMemberContactActionsView( - contact: Contact.sampleData, - groupDirectInv: GroupDirectInvitation.sampleData - ) +struct ContextMemberContactActionsView_Previews: PreviewProvider { + static var previews: some View { + ContextMemberContactActionsView( + contact: Contact.sampleData, + groupDirectInv: GroupDirectInvitation.sampleData + ) + } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index e9913053ea..8bbbb71d6e 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -98,9 +98,11 @@ func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMe } } -#Preview { - ContextPendingMemberActionsView( - groupInfo: GroupInfo.sampleData, - member: GroupMember.sampleData - ) +struct ContextPendingMemberActionsView_Previews: PreviewProvider { + static var previews: some View { + ContextPendingMemberActionsView( + groupInfo: GroupInfo.sampleData, + member: GroupMember.sampleData + ) + } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift index 9047eaf84b..4803b57719 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift @@ -300,9 +300,11 @@ struct ContextProfilePickerView: View { } } -#Preview { - ContextProfilePickerView( - chat: Chat.sampleData, - selectedUser: User.sampleData - ) +struct ContextProfilePickerView_Previews: PreviewProvider { + static var previews: some View { + ContextProfilePickerView( + chat: Chat.sampleData, + selectedUser: User.sampleData + ) + } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 225e83c014..dbded8e39b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -284,7 +284,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), - lastUnfocusedDate: Binding.constant(.now), + lastUnfocusedDate: Binding.constant(previewDate), placeholder: Binding.constant("Placeholder"), selectedRange: Binding.constant(NSRange(location: 0, length: 0)), onImagesAdded: { _ in } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 1d75f38715..2cfa6b2b0a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -446,7 +446,7 @@ struct SendMessageView_Previews: PreviewProvider { static var previews: some View { @State var composeStateNew = ComposeState() @State var selectedRange = NSRange() - let ci = ChatItem.getSample(1, .directSnd, .now, "hello") + let ci = ChatItem.getSample(1, .directSnd, previewDate, "hello") @State var composeStateEditing = ComposeState(editingItem: ci) @State var selectedRangeEditing = NSRange() @State var sendEnabled: Bool = true @@ -461,7 +461,7 @@ struct SendMessageView_Previews: PreviewProvider { sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true), - keyboardHiddenDate: Binding.constant(Date.now) + keyboardHiddenDate: Binding.constant(previewDate) ) } VStack { @@ -473,7 +473,7 @@ struct SendMessageView_Previews: PreviewProvider { sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: Binding.constant(true), - keyboardHiddenDate: Binding.constant(Date.now) + keyboardHiddenDate: Binding.constant(previewDate) ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index 6b7f5b65fc..f7cadc6d6f 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -26,7 +26,7 @@ struct ChannelMembersView: View { .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - if groupInfo.isOwner { + if groupInfo.canModerate { List { Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary) @@ -104,9 +104,11 @@ struct ChannelMembersView: View { } } -#Preview { - ChannelMembersView( - chat: Chat.sampleData, - groupInfo: GroupInfo.sampleData - ) +struct ChannelMembersView_Previews: PreviewProvider { + static var previews: some View { + ChannelMembersView( + chat: Chat.sampleData, + groupInfo: GroupInfo.sampleData + ) + } } diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index aa94f5b346..b22b2261d6 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -170,6 +170,8 @@ func hostFromRelayLink(_ link: String) -> String { return link } -#Preview { - ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData) +struct ChannelRelaysView_Previews: PreviewProvider { + static var previews: some View { + ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData) + } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 1be8070259..d2644e600f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -125,7 +125,7 @@ struct GroupChatInfoView: View { Label("Share via chat", systemImage: "arrowshape.turn.up.forward") } } - if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { + if groupInfo.canModerate || members.contains(where: { $0.wrapped.memberRole >= .owner }) { channelMembersButton() } if groupInfo.membership.memberRole >= .moderator { @@ -734,7 +734,7 @@ struct GroupChatInfoView: View { } private func channelMembersButton() -> some View { - let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors" + let label: LocalizedStringKey = groupInfo.canModerate ? "Subscribers" : "Owners & contributors" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ebbdc6b6a3..d529bc4e76 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -38,6 +38,8 @@ struct GroupMemberInfoView: View { switch groupMember.wrapped.memberRole { case .relay: "Relay" case .owner: "Owner" + case .moderator: "Moderator" + case .member: "Contributor" default: "Subscriber" } } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index da15ab98a5..eed907a643 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -50,8 +50,21 @@ struct GroupPreferencesView: View { featureSection(.history, $preferences.history.enable) featureSection(.support, $preferences.support.enable, disabled: true) } else { - // hidden until message signing is user-facing (recipient-only stage) -// featureSection(.signMessages, $preferences.signMessages.enable) + featureSection(.signMessages, $preferences.signMessages.enable) + .onChange(of: preferences.signMessages.enable) { enable in + if enable == .on && currentPreferences.signMessages.enable != .on { + showAlert( + NSLocalizedString("Sign messages?", comment: "alert title"), + message: NSLocalizedString("Messages sent before will show \"signature missing\" to subscribers who join later.", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in }, + UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel) { _ in + preferences.signMessages.enable = .off + } + ]} + ) + } + } featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) featureSection(.reactions, $preferences.reactions.enable) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift index d80615b5d2..7213363471 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberAdmissionView.swift @@ -82,12 +82,14 @@ struct MemberAdmissionView: View { } } -#Preview { - MemberAdmissionView( - groupInfo: Binding.constant(GroupInfo.sampleData), - admission: Binding.constant(GroupMemberAdmission.sampleData), - currentAdmission: GroupMemberAdmission.sampleData, - creatingGroup: false, - saveAdmission: {} - ) +struct MemberAdmissionView_Previews: PreviewProvider { + static var previews: some View { + MemberAdmissionView( + groupInfo: Binding.constant(GroupInfo.sampleData), + admission: Binding.constant(GroupMemberAdmission.sampleData), + currentAdmission: GroupMemberAdmission.sampleData, + creatingGroup: false, + saveAdmission: {} + ) + } } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift index 74cb702d21..63bc83eb1d 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportChatToolbar.swift @@ -36,9 +36,11 @@ struct MemberSupportChatToolbar: View { } } -#Preview { - MemberSupportChatToolbar( - groupMember: GroupMember.sampleData - ) - .environmentObject(CurrentColors.toAppTheme()) +struct MemberSupportChatToolbar_Previews: PreviewProvider { + static var previews: some View { + MemberSupportChatToolbar( + groupMember: GroupMember.sampleData + ) + .environmentObject(CurrentColors.toAppTheme()) + } } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index da9d56a699..721f9b08a3 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -291,9 +291,11 @@ func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { } } -#Preview { - MemberSupportView( - groupInfo: GroupInfo.sampleData, - scrollToItemId: Binding.constant(nil) - ) +struct MemberSupportView_Previews: PreviewProvider { + static var previews: some View { + MemberSupportView( + groupInfo: GroupInfo.sampleData, + scrollToItemId: Binding.constant(nil) + ) + } } diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index e2092f7a24..7fade11db0 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -32,13 +32,18 @@ struct SecondaryChatView: View { } } -#Preview { - SecondaryChatView( - chat: Chat( - chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), - chatItems: [], - chatStats: ChatStats() - ), - scrollToItemId: Binding.constant(nil) - ) +struct SecondaryChatView_Previews: PreviewProvider { + static var previews: some View { + let chatModel = ChatModel() + chatModel.secondaryIM = ItemsModel(secondaryIMFilter: .groupChatScopeContext(groupScopeInfo: .memberSupport(groupMember_: GroupMember.sampleData))) + return SecondaryChatView( + chat: Chat( + chatInfo: .group(groupInfo: GroupInfo.sampleData, groupChatScope: .memberSupport(groupMember_: GroupMember.sampleData)), + chatItems: [], + chatStats: ChatStats() + ), + scrollToItemId: Binding.constant(nil) + ) + .environmentObject(chatModel) + } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 0ed78401b0..fb46fd83ed 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -781,17 +781,19 @@ func showErrorAlert(_ error: Error, _ title: String) { struct ChatListNavLink_Previews: PreviewProvider { static var previews: some View { @State var chatId: String? = "@1" + var contactRequest = UserContactRequest.sampleData + contactRequest.updatedAt = previewDate return Group { ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello")] ), parentSheet: .constant(nil)) ChatListNavLink(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello")] ), parentSheet: .constant(nil)) ChatListNavLink(chat: Chat( - chatInfo: ChatInfo.sampleData.contactRequest, + chatInfo: .contactRequest(contactRequest: contactRequest), chatItems: [] ), parentSheet: .constant(nil)) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 43b02d70ec..7be726deff 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -379,18 +379,39 @@ 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 + // the onboarding cards replace the whole chat list, and the badge alert banner lives in the + // list - a supporter is not a newcomer, and must be told even with no conversations yet private var shouldShowOnboarding: Bool { - !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && !supportEnded && !badgeIssueFailed + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations && bannerBadgeAlert == nil } - private var supportEnded: Bool { - badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId + // the current profile's badge alert, if it is a kind the chat list has a banner for + private var bannerBadgeAlert: BadgeAlert? { + guard let alert = badgeModel.alert, badgeModel.userId == chatModel.currentUser?.userId else { return nil } + switch alert.kind { + case .supportEnded, .issueFailed, .subscriptionEnded, .paymentIssue, .subscriptionCancelled, .renewalApproaching: return alert + case .prepaidEnding, .unknown: return nil + } } - private var badgeIssueFailed: Bool { - badgeModel.alert?.kind == .issueFailed && badgeModel.userId == chatModel.currentUser?.userId + private var renewalPrice: String? { + guard let badgeState = badgeModel.badgeState, let subscription = badgeState.subscription else { return nil } + return BadgeStore.shared.subscriptionPrice(badgeState.badgeType, planMonths: subscription.planMonths) + } + + private var renewalFailure: LocalizedStringKey { + guard let issueError = badgeModel.badgeState?.issueError else { return "Your badge could not be renewed." } + return "\(issueError.reason.text)" + } + + private func badgeAlertBanner(_ title: LocalizedStringKey, _ subtitle: LocalizedStringKey, warning: Bool = false, dismissTitle: String) -> some View { + SupportSimpleXBanner( + title: title, + subtitle: subtitle, + warning: warning, + onTap: { showBadgesSheet = true }, + onDismiss: { showBadgeAlertDismissAlert(dismissTitle) } + ) } private func showBadgeAlertDismissAlert(_ title: String) { @@ -481,34 +502,36 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - // one slot: a badge the user paid for ending outranks the pitch to get one - if supportEnded, let alert = badgeModel.alert { - SupportSimpleXBanner( - title: "Your badge expired", - subtitle: "Your badge expired on \(alert.dateText).", - onTap: { showBadgesSheet = true }, - onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Your badge expired", comment: "alert title")) } - ) + // one slot: an alert about a badge the user paid for outranks the pitch to get one + if let alert = bannerBadgeAlert { + Group { + switch alert.kind { + case .supportEnded: + badgeAlertBanner("Your badge expired", "Your badge expired on \(alert.dateText).", dismissTitle: NSLocalizedString("Your badge expired", comment: "alert title")) + case .issueFailed: + badgeAlertBanner("Badge renewal failed", renewalFailure, warning: true, dismissTitle: NSLocalizedString("Badge renewal failed", comment: "alert title")) + case .subscriptionEnded: + badgeAlertBanner("Your subscription ended", "Your badge subscription ended on \(alert.dateText).", dismissTitle: NSLocalizedString("Your subscription ended", comment: "alert title")) + case .paymentIssue: + badgeAlertBanner("Badge payment problem", "The store could not charge your payment method.", warning: true, dismissTitle: NSLocalizedString("Badge payment problem", comment: "alert title")) + case .subscriptionCancelled: + badgeAlertBanner("Subscription cancelled", "Your badge stays until \(alert.dateText).", dismissTitle: NSLocalizedString("Subscription cancelled", comment: "alert title")) + case .renewalApproaching: + if let price = renewalPrice { + badgeAlertBanner("Your badge renews soon", "Renews on \(alert.dateText) for \(price).", dismissTitle: NSLocalizedString("Your badge renews soon", comment: "alert title")) + } else { + badgeAlertBanner("Your badge renews soon", "Renews on \(alert.dateText).", dismissTitle: NSLocalizedString("Your badge renews soon", comment: "alert title")) + } + case .prepaidEnding, .unknown: + EmptyView() + } + } .padding(.vertical, 3) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) .listRowBackground(Color.clear) .zIndex(1) - .onAppear { chatModel.chatListBanner = .badgeExpired } - } else if badgeIssueFailed { - SupportSimpleXBanner( - title: "Badge renewal failed", - subtitle: "Tap for details", - warning: true, - onTap: { showBadgesSheet = true }, - onDismiss: { showBadgeAlertDismissAlert(NSLocalizedString("Badge renewal failed", comment: "alert title")) } - ) - .padding(.vertical, 3) - .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .zIndex(1) - .onAppear { chatModel.chatListBanner = .badgeIssueFailed } + .onAppear { chatModel.chatListBanner = .badgeAlert } // noShownBadge is false until the badge state loads: the pitch must not lock the slot and then // be hidden by a supporter's badge arriving a moment later, leaving the slot empty for the session } else if chatModel.bannerSlotFree(for: .badgePitch) && !supporterBannerShown && noShownBadge() && chatModel.chats.count > 3 { @@ -1236,17 +1259,19 @@ struct ChatListView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() + var contactRequest = UserContactRequest.sampleData + contactRequest.updatedAt = previewDate chatModel.updateChats([ ChatData( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello")] ), ChatData( chatInfo: ChatInfo.sampleData.group, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "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.")] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "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.")] ), ChatData( - chatInfo: ChatInfo.sampleData.contactRequest, + chatInfo: .contactRequest(contactRequest: contactRequest), chatItems: [] ) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 7f50f09a31..70cacf5bc3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -573,25 +573,25 @@ struct ChatPreviewView_Previews: PreviewProvider { ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete))] ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: previewDate))] ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, - chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. 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.")], + chatItems: [ChatItem.getSample(1, .directSnd, previewDate, "Lorem ipsum dolor sit amet, d. 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.")], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) ), progressByTimeout: Binding.constant(false)) } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index f5156d86b8..06ccde1361 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -76,7 +76,9 @@ struct ContactConnectionView: View { struct ContactConnectionView_Previews: PreviewProvider { static var previews: some View { - ContactConnectionView(chat: Chat(chatInfo: ChatInfo.sampleData.contactConnection)) + var contactConnection = PendingContactConnection.getSampleData() + contactConnection.updatedAt = previewDate + return ContactConnectionView(chat: Chat(chatInfo: .contactConnection(contactConnection: contactConnection))) .previewLayout(.fixed(width: 360, height: 80)) } } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index 341bc10655..5a27271b16 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -55,7 +55,9 @@ struct ContactRequestView: View { struct ContactRequestView_Previews: PreviewProvider { static var previews: some View { - ContactRequestView(contactRequest: UserContactRequest.sampleData, chat: Chat(chatInfo: ChatInfo.sampleData.contactRequest , chatItems: [])) + var contactRequest = UserContactRequest.sampleData + contactRequest.updatedAt = previewDate + return ContactRequestView(contactRequest: contactRequest, chat: Chat(chatInfo: ChatInfo.sampleData.contactRequest , chatItems: [])) .previewLayout(.fixed(width: 360, height: 80)) } } diff --git a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift index 0ee07ea828..99e8c90cc9 100644 --- a/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift +++ b/apps/ios/Shared/Views/ChatList/GetStakeBanner.swift @@ -105,7 +105,9 @@ struct BannerDismissButton: View { } } -#Preview { - GetStakeBanner(showDismiss: true, onTap: {}, onDismiss: {}) - .padding() +struct GetStakeBanner_Previews: PreviewProvider { + static var previews: some View { + GetStakeBanner(showDismiss: true, onTap: {}, onDismiss: {}) + .padding() + } } diff --git a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift index 132a19d7e7..4e1b367c96 100644 --- a/apps/ios/Shared/Views/ChatList/OneHandUICard.swift +++ b/apps/ios/Shared/Views/ChatList/OneHandUICard.swift @@ -81,6 +81,8 @@ struct OneHandUICard: View { } } -#Preview { - OneHandUICard() +struct OneHandUICard_Previews: PreviewProvider { + static var previews: some View { + OneHandUICard() + } } diff --git a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift index ee7605dbd2..6842a33cb0 100644 --- a/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift +++ b/apps/ios/Shared/Views/ChatList/ServersSummaryView.swift @@ -737,6 +737,8 @@ struct DetailedXFTPStatsView: View { } } -#Preview { - ServersSummaryView() +struct ServersSummaryView_Previews: PreviewProvider { + static var previews: some View { + ServersSummaryView() + } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index fa953b2ec1..bd8c93b146 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -531,6 +531,8 @@ struct RelayProgressIndicator: View { } } -#Preview { - AddChannelView() +struct AddChannelView_Previews: PreviewProvider { + static var previews: some View { + AddChannelView() + } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 416dc32308..60aca9be1f 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -497,6 +497,8 @@ struct DeletedChats: View { } } -#Preview { - NewChatMenuButton(showNewChatSheet: Binding.constant(false)) +struct NewChatMenuButton_Previews: PreviewProvider { + static var previews: some View { + NewChatMenuButton(showNewChatSheet: Binding.constant(false)) + } } diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 2c21682dba..3c4c12ae2b 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -398,6 +398,8 @@ struct ChooseServerOperatorsInfoView: View { } } -#Preview { - OnboardingConditionsView(selectedOperatorIds: []) +struct OnboardingConditionsView_Previews: PreviewProvider { + static var previews: some View { + OnboardingConditionsView(selectedOperatorIds: []) + } } diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 24ae5cffca..e7393ecc1c 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -539,6 +539,8 @@ struct ConnectDesktopView: View { } } -#Preview { - ConnectDesktopView() +struct ConnectDesktopView_Previews: PreviewProvider { + static var previews: some View { + ConnectDesktopView() + } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index ac143fe044..32209e97fa 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -194,8 +194,8 @@ struct TerminalView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.terminalItems = [ - .err(.now, APIResult.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected), - .err(.now, APIResult.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected) + .err(previewDate, APIResult.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected), + .err(previewDate + 1, APIResult.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected) ] return NavigationView { TerminalView() diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 8095c0297e..c181a19a60 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -514,5 +514,6 @@ func updateOperatorsConditionsAcceptance(_ usvs: Binding<[UserOperatorServers]>, struct NetworkServersView_Previews: PreviewProvider { static var previews: some View { NetworkAndServers() + .environmentObject(SaveableSettings()) } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index 0a3c82b4dd..8d43159f8c 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -151,10 +151,12 @@ func addServer( } } -#Preview { - NewServerView( - userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]), - serverWarnings: Binding.constant([]) - ) +struct NewServerView_Previews: PreviewProvider { + static var previews: some View { + NewServerView( + userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]) + ) + } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index 4e2f1992d6..55ccc81850 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -623,13 +623,15 @@ func conditionsLinkButton() -> some View { } } -#Preview { - OperatorView( - currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]), - serverWarnings: Binding.constant([]), - operatorIndex: 1, - useOperator: ServerOperator.sampleData1.enabled - ) +struct OperatorView_Previews: PreviewProvider { + static var previews: some View { + OperatorView( + currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), + operatorIndex: 1, + useOperator: ServerOperator.sampleData1.enabled + ) + } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index a92491edef..0e03aa4cd7 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -451,7 +451,7 @@ struct YourServersView_Previews: PreviewProvider { userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), serverWarnings: Binding.constant([]), - operatorIndex: 1 + operatorIndex: 0 ) } } diff --git a/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md new file mode 100644 index 0000000000..3ce290fc5c --- /dev/null +++ b/apps/ios/SimpleX Snapshots/References/ASSETS_LICENSE.md @@ -0,0 +1,18 @@ +# Application Graphic Assets License + +Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. + +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, visual designs, and screenshots of the applications, such as the snapshot images of the tests - are proprietary and are not licensed under the AGPLv3 that covers the application source code. + +## Permitted use + +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. The snapshot images of the tests are not needed to build the application and are not part of it. +- Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. + +## Not permitted + +All other use, including modification, redistribution, or incorporation into other works, is not permitted without prior written permission from SimpleX Chat Ltd. + +## Contact + +To request permission, contact chat@simplex.chat. diff --git a/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-dark-large.png b/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-dark-large.png new file mode 100644 index 0000000000..778bdb3066 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-light-large.png b/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-light-large.png new file mode 100644 index 0000000000..255acf2ec1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ActiveCallOverlay-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddChannelView-dark-large.png b/apps/ios/SimpleX Snapshots/References/AddChannelView-dark-large.png new file mode 100644 index 0000000000..32e8bca138 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddChannelView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddChannelView-light-large.png b/apps/ios/SimpleX Snapshots/References/AddChannelView-light-large.png new file mode 100644 index 0000000000..a5fcf3b255 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddChannelView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-dark-large.png b/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-dark-large.png new file mode 100644 index 0000000000..877f795ab4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-light-large.png b/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-light-large.png new file mode 100644 index 0000000000..7207d98edb Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddContactLearnMore-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-dark-large.png b/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-dark-large.png new file mode 100644 index 0000000000..661836f487 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-light-large.png b/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-light-large.png new file mode 100644 index 0000000000..2b6a59954d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddGroupMembersView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddGroupView-dark-large.png b/apps/ios/SimpleX Snapshots/References/AddGroupView-dark-large.png new file mode 100644 index 0000000000..8521d3f799 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddGroupView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AddGroupView-light-large.png b/apps/ios/SimpleX Snapshots/References/AddGroupView-light-large.png new file mode 100644 index 0000000000..90411da71d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AddGroupView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-dark-large.png b/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-dark-large.png new file mode 100644 index 0000000000..34f0cbfc5d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-light-large.png b/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-light-large.png new file mode 100644 index 0000000000..2e69202157 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/AdvancedNetworkSettings-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-dark-large.png b/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-dark-large.png new file mode 100644 index 0000000000..39b32f83ed Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-light-large.png b/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-light-large.png new file mode 100644 index 0000000000..7dec008a81 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesCheckOrderView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-dark-large.png b/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-dark-large.png new file mode 100644 index 0000000000..75e1af244f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-light-large.png b/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-light-large.png new file mode 100644 index 0000000000..5d968d9965 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesHowItWorksView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-dark-large.png b/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-dark-large.png new file mode 100644 index 0000000000..a5dbcafefc Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-light-large.png b/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-light-large.png new file mode 100644 index 0000000000..3c6f2bc7c2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesHowLongView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-dark-large.png new file mode 100644 index 0000000000..bfa02ffc5c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-light-large.png b/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-light-large.png new file mode 100644 index 0000000000..2b57644e18 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesRedeemCodeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-dark-large.png b/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-dark-large.png new file mode 100644 index 0000000000..318a7f1948 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-light-large.png b/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-light-large.png new file mode 100644 index 0000000000..0d5b2f03de Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/BadgesSupportSimplexView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-accessibility-medium.png new file mode 100644 index 0000000000..d9fb1383d1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..a5e3812580 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-large.png new file mode 100644 index 0000000000..7f4900418b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-accessibility-medium.png new file mode 100644 index 0000000000..ca4dfc1bcd Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..e11b521914 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-large.png new file mode 100644 index 0000000000..8e3c7c68ab Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIChatFeatureView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-accessibility-medium.png new file mode 100644 index 0000000000..e4499188eb Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..a10f2d80c6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-large.png new file mode 100644 index 0000000000..fe08b45c09 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIEventView-light-accessibility-medium.png new file mode 100644 index 0000000000..6050f1ae6f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIEventView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..3661cc5461 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIEventView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIEventView-light-large.png new file mode 100644 index 0000000000..e6be61457f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIEventView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-accessibility-medium.png new file mode 100644 index 0000000000..3daf567f32 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..714ae95c41 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-large.png new file mode 100644 index 0000000000..f70f8f446f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-accessibility-medium.png new file mode 100644 index 0000000000..c7e3d27686 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..32da139571 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-large.png new file mode 100644 index 0000000000..f8a6f8845f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFeaturePreferenceView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-accessibility-medium.png new file mode 100644 index 0000000000..78d69983b7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..476bdd63e9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-large.png new file mode 100644 index 0000000000..bf89bc25ce Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIFileView-light-accessibility-medium.png new file mode 100644 index 0000000000..5c5335c7d0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIFileView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..c368ae5bdf Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIFileView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIFileView-light-large.png new file mode 100644 index 0000000000..401a531b8f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIFileView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-accessibility-medium.png new file mode 100644 index 0000000000..24b86248b4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..f2b414114d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-large.png new file mode 100644 index 0000000000..386df27744 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-accessibility-medium.png new file mode 100644 index 0000000000..c680d7c9bb Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..12e44fca22 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-large.png new file mode 100644 index 0000000000..596dc0851d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIGroupInvitationView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-accessibility-medium.png new file mode 100644 index 0000000000..620f898b6a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..32bd55d1c9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-large.png new file mode 100644 index 0000000000..a7b6c3d459 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-accessibility-medium.png new file mode 100644 index 0000000000..aeab893fe8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..ac748c6030 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-large.png new file mode 100644 index 0000000000..d534a1f01e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIInvalidJSONView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-accessibility-medium.png new file mode 100644 index 0000000000..0f5b1a2a79 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..2b1db9f6b9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-large.png new file mode 100644 index 0000000000..df760ef53f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-accessibility-medium.png new file mode 100644 index 0000000000..9e68d6b793 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..5535eeb14f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-large.png new file mode 100644 index 0000000000..8ad33d09f8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMemberCreatedContactView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-accessibility-medium.png new file mode 100644 index 0000000000..b8fe68610f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..3c960c0f1f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-large.png new file mode 100644 index 0000000000..cde5662fb9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-accessibility-medium.png new file mode 100644 index 0000000000..26256b545e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..48989ed1a9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIMetaView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-large.png new file mode 100644 index 0000000000..42e73b96e6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIMetaView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-accessibility-medium.png new file mode 100644 index 0000000000..66dd209021 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..68201d8915 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-large.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-large.png new file mode 100644 index 0000000000..3ff41e7f60 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-accessibility-medium.png new file mode 100644 index 0000000000..6a1c2663c0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..af885bd093 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-large.png b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-large.png new file mode 100644 index 0000000000..304e439753 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CIVoiceView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CallSettings-dark-large.png b/apps/ios/SimpleX Snapshots/References/CallSettings-dark-large.png new file mode 100644 index 0000000000..328356fe9b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CallSettings-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CallSettings-light-large.png b/apps/ios/SimpleX Snapshots/References/CallSettings-light-large.png new file mode 100644 index 0000000000..96d17b1708 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CallSettings-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChannelMembersView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChannelMembersView-dark-large.png new file mode 100644 index 0000000000..d3ac0fd978 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChannelMembersView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChannelMembersView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChannelMembersView-light-large.png new file mode 100644 index 0000000000..b3331f9b2b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChannelMembersView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-dark-large.png new file mode 100644 index 0000000000..f0576022e4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-light-large.png new file mode 100644 index 0000000000..084773f215 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChannelRelaysView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatHelp-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatHelp-dark-large.png new file mode 100644 index 0000000000..afa166cb0e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatHelp-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatHelp-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatHelp-light-large.png new file mode 100644 index 0000000000..22ae1dd89e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatHelp-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png new file mode 100644 index 0000000000..9d4f92bbac Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png new file mode 100644 index 0000000000..7a62931eb3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoImage-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-dark-large.png new file mode 100644 index 0000000000..77e1183765 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-light-large.png new file mode 100644 index 0000000000..2d3cf9f4fa Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoToolbar-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoView-dark-large.png new file mode 100644 index 0000000000..75e03c1667 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatInfoView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatInfoView-light-large.png new file mode 100644 index 0000000000..0c99a9a7de Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatInfoView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-dark-large.png new file mode 100644 index 0000000000..f0bb34b36d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-light-large.png new file mode 100644 index 0000000000..17000f109a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemForwardingView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-dark-large.png new file mode 100644 index 0000000000..7d428a7d85 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-light-large.png new file mode 100644 index 0000000000..a8f303d453 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemInfoView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..24eb7b9745 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..edd09d09c4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-large.png new file mode 100644 index 0000000000..21385245f6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..0b6f2a18d3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..476e487884 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-large.png new file mode 100644 index 0000000000..6ac5fb4960 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-accessibility-medium.png new file mode 100644 index 0000000000..0c26eae61d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..8a1dd80a1d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-large.png new file mode 100644 index 0000000000..053394982d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-accessibility-medium.png new file mode 100644 index 0000000000..0ca87e33ee Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..fab866286e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-large.png new file mode 100644 index 0000000000..faaca96eb7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatItemView_NonMsgContentDeleted-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatListNavLink-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatListNavLink-dark-large.png new file mode 100644 index 0000000000..2c8b4bc2a9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatListNavLink-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatListNavLink-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatListNavLink-light-large.png new file mode 100644 index 0000000000..72da7abf90 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatListNavLink-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatListView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatListView-dark-large.png new file mode 100644 index 0000000000..c9dbc580a7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatListView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatListView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatListView-light-large.png new file mode 100644 index 0000000000..0cb3569a83 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatListView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatPreviewView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatPreviewView-dark-large.png new file mode 100644 index 0000000000..f3abe906b0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatPreviewView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatPreviewView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatPreviewView-light-large.png new file mode 100644 index 0000000000..52b2c48b28 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatPreviewView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ChatView-dark-large.png new file mode 100644 index 0000000000..f7b558eeb8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ChatView-light-large.png b/apps/ios/SimpleX Snapshots/References/ChatView-light-large.png new file mode 100644 index 0000000000..1e3335d4aa Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ChatView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-dark-large.png new file mode 100644 index 0000000000..7329687703 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-light-large.png b/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-light-large.png new file mode 100644 index 0000000000..ce05cb0884 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ComposeVoiceView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-dark-large.png new file mode 100644 index 0000000000..9a5bc82a85 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-light-large.png b/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-light-large.png new file mode 100644 index 0000000000..137e9e0382 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ConnectDesktopView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-dark-large.png new file mode 100644 index 0000000000..9673f00f39 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-light-large.png b/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-light-large.png new file mode 100644 index 0000000000..ffb58b4d3f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactConnectionInfo-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactConnectionView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContactConnectionView-dark-large.png new file mode 100644 index 0000000000..2d2a36c883 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactConnectionView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactConnectionView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContactConnectionView-light-large.png new file mode 100644 index 0000000000..a9a4cc52c8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactConnectionView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-dark-large.png new file mode 100644 index 0000000000..b06c2d60a4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-light-large.png new file mode 100644 index 0000000000..c3527f213b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactPreferencesView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactRequestView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContactRequestView-dark-large.png new file mode 100644 index 0000000000..c51d86a15f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactRequestView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContactRequestView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContactRequestView-light-large.png new file mode 100644 index 0000000000..644996da7a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContactRequestView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-dark-large.png new file mode 100644 index 0000000000..702ef8b5d1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-light-large.png new file mode 100644 index 0000000000..8ec3c4a1ab Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextContactRequestActionsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContextItemView-dark-large.png new file mode 100644 index 0000000000..954b07ecc8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContextItemView-light-large.png new file mode 100644 index 0000000000..12242e476d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-dark-large.png new file mode 100644 index 0000000000..702ef8b5d1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-light-large.png new file mode 100644 index 0000000000..8ec3c4a1ab Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextMemberContactActionsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-dark-large.png new file mode 100644 index 0000000000..a0abb6e5b7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-light-large.png new file mode 100644 index 0000000000..143c068397 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextPendingMemberActionsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-dark-large.png new file mode 100644 index 0000000000..b173f26b5c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-light-large.png b/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-light-large.png new file mode 100644 index 0000000000..f6eefde14a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ContextProfilePickerView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CreateProfile-dark-large.png b/apps/ios/SimpleX Snapshots/References/CreateProfile-dark-large.png new file mode 100644 index 0000000000..f89c607f22 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CreateProfile-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CreateProfile-light-large.png b/apps/ios/SimpleX Snapshots/References/CreateProfile-light-large.png new file mode 100644 index 0000000000..bc1d4e96d8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CreateProfile-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-dark-large.png b/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-dark-large.png new file mode 100644 index 0000000000..89ad7b45f5 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-light-large.png b/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-light-large.png new file mode 100644 index 0000000000..fac7009208 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CreateSimpleXAddress-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CustomTimePicker-dark-large.png b/apps/ios/SimpleX Snapshots/References/CustomTimePicker-dark-large.png new file mode 100644 index 0000000000..ca2a52d6bc Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CustomTimePicker-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/CustomTimePicker-light-large.png b/apps/ios/SimpleX Snapshots/References/CustomTimePicker-light-large.png new file mode 100644 index 0000000000..7755853789 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/CustomTimePicker-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-dark-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-dark-large.png new file mode 100644 index 0000000000..c397d6f491 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-light-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-light-large.png new file mode 100644 index 0000000000..e80b7d9c5c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseEncryptionView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-dark-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-dark-large.png new file mode 100644 index 0000000000..83de2538fe Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-light-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-light-large.png new file mode 100644 index 0000000000..e6edba4901 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseErrorView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseView-dark-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseView-dark-large.png new file mode 100644 index 0000000000..28b0233fef Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DatabaseView-light-large.png b/apps/ios/SimpleX Snapshots/References/DatabaseView-light-large.png new file mode 100644 index 0000000000..2aaf63aac3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DatabaseView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..a2c693deae Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..28619987b3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-large.png new file mode 100644 index 0000000000..627f92de65 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..1ace029570 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..eafa9ebd45 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-large.png new file mode 100644 index 0000000000..79434d6957 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeletedItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DetermineWidth-dark-large.png b/apps/ios/SimpleX Snapshots/References/DetermineWidth-dark-large.png new file mode 100644 index 0000000000..8197ce6773 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DetermineWidth-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DetermineWidth-light-large.png b/apps/ios/SimpleX Snapshots/References/DetermineWidth-light-large.png new file mode 100644 index 0000000000..b9ff281660 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DetermineWidth-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeveloperView-dark-large.png b/apps/ios/SimpleX Snapshots/References/DeveloperView-dark-large.png new file mode 100644 index 0000000000..a9729487fa Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeveloperView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/DeveloperView-light-large.png b/apps/ios/SimpleX Snapshots/References/DeveloperView-light-large.png new file mode 100644 index 0000000000..40b79ca7ac Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/DeveloperView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..a798a154b6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..3a225d8f69 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-large.png new file mode 100644 index 0000000000..4028d8dddd Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..8426904a54 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..e9dd155f73 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-large.png new file mode 100644 index 0000000000..a5a123aa8c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/EmojiItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-accessibility-medium.png new file mode 100644 index 0000000000..fe3367452a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..c93698ca4d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-large.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-large.png new file mode 100644 index 0000000000..0b4a0335a6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-accessibility-medium.png new file mode 100644 index 0000000000..ba111aa54c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..43bf7625b9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-large.png b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-large.png new file mode 100644 index 0000000000..54ee1da7f4 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedCIVoiceView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..c673e73852 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..b206855b67 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-large.png new file mode 100644 index 0000000000..49caa88a01 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..94eec8a773 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..ad054b9c3a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-large.png new file mode 100644 index 0000000000..e14bcfce52 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-accessibility-medium.png new file mode 100644 index 0000000000..cc8e6c4732 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..c9271abe03 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-large.png new file mode 100644 index 0000000000..d8165e594e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-accessibility-medium.png new file mode 100644 index 0000000000..c24d15b228 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..887a37e635 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-large.png new file mode 100644 index 0000000000..0c94315030 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Deleted-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-accessibility-medium.png new file mode 100644 index 0000000000..4797f73cea Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..e8fb68712b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-large.png new file mode 100644 index 0000000000..629c0af062 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-accessibility-medium.png new file mode 100644 index 0000000000..e2c67c6190 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..db550c6e8f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-large.png b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-large.png new file mode 100644 index 0000000000..d64be9d428 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/FramedItemView_Edited-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GetStakeBanner-dark-large.png b/apps/ios/SimpleX Snapshots/References/GetStakeBanner-dark-large.png new file mode 100644 index 0000000000..4c1f38f193 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GetStakeBanner-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GetStakeBanner-light-large.png b/apps/ios/SimpleX Snapshots/References/GetStakeBanner-light-large.png new file mode 100644 index 0000000000..7cfa8dcc36 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GetStakeBanner-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-dark-large.png new file mode 100644 index 0000000000..f0e88b071c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-light-large.png new file mode 100644 index 0000000000..cfb832ff82 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupChatInfoView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupLinkView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupLinkView-dark-large.png new file mode 100644 index 0000000000..cf46fb2341 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupLinkView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupLinkView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupLinkView-light-large.png new file mode 100644 index 0000000000..05f6fbfe2d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupLinkView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-dark-large.png new file mode 100644 index 0000000000..9c3b36a82c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-light-large.png new file mode 100644 index 0000000000..e536f543b6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupMemberInfoView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-dark-large.png new file mode 100644 index 0000000000..cd639e3967 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-light-large.png new file mode 100644 index 0000000000..ba55b116af Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupPreferencesView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupProfileView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupProfileView-dark-large.png new file mode 100644 index 0000000000..f8174698a5 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupProfileView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupProfileView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupProfileView-light-large.png new file mode 100644 index 0000000000..34d7120b64 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupProfileView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-dark-large.png new file mode 100644 index 0000000000..f8174698a5 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-light-large.png b/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-light-large.png new file mode 100644 index 0000000000..34d7120b64 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/GroupWelcomeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IncognitoHelp-dark-large.png b/apps/ios/SimpleX Snapshots/References/IncognitoHelp-dark-large.png new file mode 100644 index 0000000000..af8f806674 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IncognitoHelp-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IncognitoHelp-light-large.png b/apps/ios/SimpleX Snapshots/References/IncognitoHelp-light-large.png new file mode 100644 index 0000000000..e87a164e16 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IncognitoHelp-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IncomingCallView-dark-large.png b/apps/ios/SimpleX Snapshots/References/IncomingCallView-dark-large.png new file mode 100644 index 0000000000..18fa9ef303 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IncomingCallView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IncomingCallView-light-large.png b/apps/ios/SimpleX Snapshots/References/IncomingCallView-light-large.png new file mode 100644 index 0000000000..d1ba30287f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IncomingCallView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..3e66bca4c1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..04b0c7a42b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-large.png new file mode 100644 index 0000000000..293e2e4f41 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..634eeef016 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..bbb4f83d00 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-large.png new file mode 100644 index 0000000000..ea9e249767 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/IntegrityErrorItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-accessibility-medium.png new file mode 100644 index 0000000000..b1cab3ccf0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..702353bab3 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-large.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-large.png new file mode 100644 index 0000000000..99e838b70a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-accessibility-medium.png new file mode 100644 index 0000000000..27e81abd42 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..1cf14badd6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-large.png b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-large.png new file mode 100644 index 0000000000..14cdd70b63 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LargeLinkPreview-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LocalAuthView-dark-large.png b/apps/ios/SimpleX Snapshots/References/LocalAuthView-dark-large.png new file mode 100644 index 0000000000..640c40abe8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LocalAuthView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/LocalAuthView-light-large.png b/apps/ios/SimpleX Snapshots/References/LocalAuthView-light-large.png new file mode 100644 index 0000000000..059627066f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/LocalAuthView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkdownHelp-dark-large.png b/apps/ios/SimpleX Snapshots/References/MarkdownHelp-dark-large.png new file mode 100644 index 0000000000..48f1621037 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkdownHelp-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkdownHelp-light-large.png b/apps/ios/SimpleX Snapshots/References/MarkdownHelp-light-large.png new file mode 100644 index 0000000000..2896da9122 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkdownHelp-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-accessibility-medium.png new file mode 100644 index 0000000000..666359aa1d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..f48a75b5bd Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-large.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-large.png new file mode 100644 index 0000000000..00903bedc8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-accessibility-medium.png new file mode 100644 index 0000000000..f4cea0e882 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..2dce5a1586 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-large.png b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-large.png new file mode 100644 index 0000000000..28fe0130a1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MarkedDeletedItemView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-dark-large.png b/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-dark-large.png new file mode 100644 index 0000000000..fcd8267a34 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-light-large.png b/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-light-large.png new file mode 100644 index 0000000000..9829a9ac09 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberAdmissionView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-dark-large.png b/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-dark-large.png new file mode 100644 index 0000000000..27b9352257 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-light-large.png b/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-light-large.png new file mode 100644 index 0000000000..a42bb606b2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberSupportChatToolbar-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberSupportView-dark-large.png b/apps/ios/SimpleX Snapshots/References/MemberSupportView-dark-large.png new file mode 100644 index 0000000000..4453018a6b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberSupportView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MemberSupportView-light-large.png b/apps/ios/SimpleX Snapshots/References/MemberSupportView-light-large.png new file mode 100644 index 0000000000..7bdd6a9e9e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MemberSupportView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MigrateToDevice-dark-large.png b/apps/ios/SimpleX Snapshots/References/MigrateToDevice-dark-large.png new file mode 100644 index 0000000000..9b61801a8d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MigrateToDevice-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MigrateToDevice-light-large.png b/apps/ios/SimpleX Snapshots/References/MigrateToDevice-light-large.png new file mode 100644 index 0000000000..8381c58498 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MigrateToDevice-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-dark-large.png b/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-dark-large.png new file mode 100644 index 0000000000..196f463dff Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-light-large.png b/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-light-large.png new file mode 100644 index 0000000000..c2b1758a4d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MigrateToGroupView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-accessibility-medium.png new file mode 100644 index 0000000000..8a6ccc0bda Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-extra-extra-extra-large.png new file mode 100644 index 0000000000..b16b666379 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-large.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-large.png new file mode 100644 index 0000000000..4f2b9b76ae Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-light-accessibility-medium.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-accessibility-medium.png new file mode 100644 index 0000000000..7811805be0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-accessibility-medium.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-light-extra-extra-extra-large.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-extra-extra-extra-large.png new file mode 100644 index 0000000000..d5ba4466f6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-extra-extra-extra-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/MsgContentView-light-large.png b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-large.png new file mode 100644 index 0000000000..c5f3ef7c9b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/MsgContentView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NativeTextEditor-dark-large.png b/apps/ios/SimpleX Snapshots/References/NativeTextEditor-dark-large.png new file mode 100644 index 0000000000..cd52e80b88 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NativeTextEditor-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NativeTextEditor-light-large.png b/apps/ios/SimpleX Snapshots/References/NativeTextEditor-light-large.png new file mode 100644 index 0000000000..7e2d126979 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NativeTextEditor-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NetworkServersView-dark-large.png b/apps/ios/SimpleX Snapshots/References/NetworkServersView-dark-large.png new file mode 100644 index 0000000000..7bc1293e16 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NetworkServersView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NetworkServersView-light-large.png b/apps/ios/SimpleX Snapshots/References/NetworkServersView-light-large.png new file mode 100644 index 0000000000..0336365202 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NetworkServersView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-dark-large.png b/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-dark-large.png new file mode 100644 index 0000000000..8965e94fab Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-light-large.png b/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-light-large.png new file mode 100644 index 0000000000..72d3922dee Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewChatMenuButton-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewFeaturesView-dark-large.png b/apps/ios/SimpleX Snapshots/References/NewFeaturesView-dark-large.png new file mode 100644 index 0000000000..9abc39e0aa Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewFeaturesView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewFeaturesView-light-large.png b/apps/ios/SimpleX Snapshots/References/NewFeaturesView-light-large.png new file mode 100644 index 0000000000..29b17d5779 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewFeaturesView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewServerView-dark-large.png b/apps/ios/SimpleX Snapshots/References/NewServerView-dark-large.png new file mode 100644 index 0000000000..43c8993bca Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewServerView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NewServerView-light-large.png b/apps/ios/SimpleX Snapshots/References/NewServerView-light-large.png new file mode 100644 index 0000000000..132ae32a7d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NewServerView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NotificationsModeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/NotificationsModeView-dark-large.png new file mode 100644 index 0000000000..6d986c94c2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NotificationsModeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NotificationsModeView-light-large.png b/apps/ios/SimpleX Snapshots/References/NotificationsModeView-light-large.png new file mode 100644 index 0000000000..8dae178a11 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NotificationsModeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NotificationsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/NotificationsView-dark-large.png new file mode 100644 index 0000000000..90438d9615 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NotificationsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/NotificationsView-light-large.png b/apps/ios/SimpleX Snapshots/References/NotificationsView-light-large.png new file mode 100644 index 0000000000..6583d3d985 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/NotificationsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-dark-large.png new file mode 100644 index 0000000000..7c34e8eaa2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-light-large.png b/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-light-large.png new file mode 100644 index 0000000000..1170055e26 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OnboardingConditionsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-dark-large.png new file mode 100644 index 0000000000..fed606b4d7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-light-large.png b/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-light-large.png new file mode 100644 index 0000000000..cf1398e7a0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OnboardingStepsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OneHandUICard-dark-large.png b/apps/ios/SimpleX Snapshots/References/OneHandUICard-dark-large.png new file mode 100644 index 0000000000..826929cbed Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OneHandUICard-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OneHandUICard-light-large.png b/apps/ios/SimpleX Snapshots/References/OneHandUICard-light-large.png new file mode 100644 index 0000000000..566ac49298 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OneHandUICard-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OperatorView-dark-large.png b/apps/ios/SimpleX Snapshots/References/OperatorView-dark-large.png new file mode 100644 index 0000000000..a569df4fb7 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OperatorView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/OperatorView-light-large.png b/apps/ios/SimpleX Snapshots/References/OperatorView-light-large.png new file mode 100644 index 0000000000..4477d79f97 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/OperatorView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PasscodeEntry-dark-large.png b/apps/ios/SimpleX Snapshots/References/PasscodeEntry-dark-large.png new file mode 100644 index 0000000000..1f559a2b6c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PasscodeEntry-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PasscodeEntry-light-large.png b/apps/ios/SimpleX Snapshots/References/PasscodeEntry-light-large.png new file mode 100644 index 0000000000..795b0c5f26 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PasscodeEntry-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PasscodeViewView-dark-large.png b/apps/ios/SimpleX Snapshots/References/PasscodeViewView-dark-large.png new file mode 100644 index 0000000000..640c40abe8 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PasscodeViewView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PasscodeViewView-light-large.png b/apps/ios/SimpleX Snapshots/References/PasscodeViewView-light-large.png new file mode 100644 index 0000000000..122a92328d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PasscodeViewView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PreferencesView-dark-large.png b/apps/ios/SimpleX Snapshots/References/PreferencesView-dark-large.png new file mode 100644 index 0000000000..770667863d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PreferencesView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PreferencesView-light-large.png b/apps/ios/SimpleX Snapshots/References/PreferencesView-light-large.png new file mode 100644 index 0000000000..126c52d62b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PreferencesView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PrivacySettings-dark-large.png b/apps/ios/SimpleX Snapshots/References/PrivacySettings-dark-large.png new file mode 100644 index 0000000000..3efb94b1b5 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PrivacySettings-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/PrivacySettings-light-large.png b/apps/ios/SimpleX Snapshots/References/PrivacySettings-light-large.png new file mode 100644 index 0000000000..9a16ea7c5c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/PrivacySettings-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProfileImage-dark-large.png b/apps/ios/SimpleX Snapshots/References/ProfileImage-dark-large.png new file mode 100644 index 0000000000..3e9dfb49b2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProfileImage-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProfileImage-light-large.png b/apps/ios/SimpleX Snapshots/References/ProfileImage-light-large.png new file mode 100644 index 0000000000..0d1eff105d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProfileImage-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-dark-large.png new file mode 100644 index 0000000000..d67b64ec5d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-light-large.png b/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-light-large.png new file mode 100644 index 0000000000..4d0bec487a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProfilePrivacyView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProtocolServerView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ProtocolServerView-dark-large.png new file mode 100644 index 0000000000..3e53efb772 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProtocolServerView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ProtocolServerView-light-large.png b/apps/ios/SimpleX Snapshots/References/ProtocolServerView-light-large.png new file mode 100644 index 0000000000..7cd08c9904 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ProtocolServerView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/QRCode-dark-large.png b/apps/ios/SimpleX Snapshots/References/QRCode-dark-large.png new file mode 100644 index 0000000000..9e088b017d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/QRCode-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/QRCode-light-large.png b/apps/ios/SimpleX Snapshots/References/QRCode-light-large.png new file mode 100644 index 0000000000..e01aca146b Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/QRCode-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/RTCServers-dark-large.png b/apps/ios/SimpleX Snapshots/References/RTCServers-dark-large.png new file mode 100644 index 0000000000..22ff629648 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/RTCServers-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/RTCServers-light-large.png b/apps/ios/SimpleX Snapshots/References/RTCServers-light-large.png new file mode 100644 index 0000000000..80914e790d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/RTCServers-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ScanCodeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/ScanCodeView-dark-large.png new file mode 100644 index 0000000000..9c97558476 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ScanCodeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ScanCodeView-light-large.png b/apps/ios/SimpleX Snapshots/References/ScanCodeView-light-large.png new file mode 100644 index 0000000000..495cc37065 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ScanCodeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-dark-large.png b/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-dark-large.png new file mode 100644 index 0000000000..767b6c47ea Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-light-large.png b/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-light-large.png new file mode 100644 index 0000000000..a03740b179 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/ScanProtocolServer-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SecondaryChatView-dark-large.png b/apps/ios/SimpleX Snapshots/References/SecondaryChatView-dark-large.png new file mode 100644 index 0000000000..a5e2997596 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SecondaryChatView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SecondaryChatView-light-large.png b/apps/ios/SimpleX Snapshots/References/SecondaryChatView-light-large.png new file mode 100644 index 0000000000..856332338d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SecondaryChatView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SendMessageView-dark-large.png b/apps/ios/SimpleX Snapshots/References/SendMessageView-dark-large.png new file mode 100644 index 0000000000..9e82e430a1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SendMessageView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SendMessageView-light-large.png b/apps/ios/SimpleX Snapshots/References/SendMessageView-light-large.png new file mode 100644 index 0000000000..2b03c0455a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SendMessageView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-dark-large.png new file mode 100644 index 0000000000..117a3dffe9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-light-large.png b/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-light-large.png new file mode 100644 index 0000000000..8fa0cb707a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SetAppPasscodeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-dark-large.png new file mode 100644 index 0000000000..1794f0afef Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-light-large.png b/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-light-large.png new file mode 100644 index 0000000000..2241c568a0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SetDeliveryReceiptsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SettingsView-dark-large.png b/apps/ios/SimpleX Snapshots/References/SettingsView-dark-large.png new file mode 100644 index 0000000000..144604330c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SettingsView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SettingsView-light-large.png b/apps/ios/SimpleX Snapshots/References/SettingsView-light-large.png new file mode 100644 index 0000000000..b30044bdc0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SettingsView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SimpleXInfo-dark-large.png b/apps/ios/SimpleX Snapshots/References/SimpleXInfo-dark-large.png new file mode 100644 index 0000000000..442089f0f0 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SimpleXInfo-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SimpleXInfo-light-large.png b/apps/ios/SimpleX Snapshots/References/SimpleXInfo-light-large.png new file mode 100644 index 0000000000..390c76f60f Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SimpleXInfo-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-dark-large.png b/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-dark-large.png new file mode 100644 index 0000000000..296b2cf5eb Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-light-large.png b/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-light-large.png new file mode 100644 index 0000000000..6e75db84c6 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SmallLinkPreview-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-dark-large.png b/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-dark-large.png new file mode 100644 index 0000000000..804e9e902c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-light-large.png b/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-light-large.png new file mode 100644 index 0000000000..c0a946f80c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/SupportSimpleXBanner-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-dark-large.png b/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-dark-large.png new file mode 100644 index 0000000000..4f9ebd9d9d Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-light-large.png b/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-light-large.png new file mode 100644 index 0000000000..3927dc37c9 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserAddressLearnMore-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserAddressView-dark-large.png b/apps/ios/SimpleX Snapshots/References/UserAddressView-dark-large.png new file mode 100644 index 0000000000..e3f1287db2 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserAddressView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserAddressView-light-large.png b/apps/ios/SimpleX Snapshots/References/UserAddressView-light-large.png new file mode 100644 index 0000000000..4ed81fc844 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserAddressView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserPicker-dark-large.png b/apps/ios/SimpleX Snapshots/References/UserPicker-dark-large.png new file mode 100644 index 0000000000..d4013544a1 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserPicker-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/UserPicker-light-large.png b/apps/ios/SimpleX Snapshots/References/UserPicker-light-large.png new file mode 100644 index 0000000000..0fb4a7c90c Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/UserPicker-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/VerifyCodeView-dark-large.png b/apps/ios/SimpleX Snapshots/References/VerifyCodeView-dark-large.png new file mode 100644 index 0000000000..7a0debd123 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/VerifyCodeView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/VerifyCodeView-light-large.png b/apps/ios/SimpleX Snapshots/References/VerifyCodeView-light-large.png new file mode 100644 index 0000000000..56e09bec1e Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/VerifyCodeView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/VersionView-dark-large.png b/apps/ios/SimpleX Snapshots/References/VersionView-dark-large.png new file mode 100644 index 0000000000..1d212c8233 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/VersionView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/VersionView-light-large.png b/apps/ios/SimpleX Snapshots/References/VersionView-light-large.png new file mode 100644 index 0000000000..b4900903db Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/VersionView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/WhySimpleX-dark-large.png b/apps/ios/SimpleX Snapshots/References/WhySimpleX-dark-large.png new file mode 100644 index 0000000000..27ec118611 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/WhySimpleX-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/WhySimpleX-light-large.png b/apps/ios/SimpleX Snapshots/References/WhySimpleX-light-large.png new file mode 100644 index 0000000000..936f092af5 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/WhySimpleX-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/YourServersView-dark-large.png b/apps/ios/SimpleX Snapshots/References/YourServersView-dark-large.png new file mode 100644 index 0000000000..7bef40095a Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/YourServersView-dark-large.png differ diff --git a/apps/ios/SimpleX Snapshots/References/YourServersView-light-large.png b/apps/ios/SimpleX Snapshots/References/YourServersView-light-large.png new file mode 100644 index 0000000000..1566185c23 Binary files /dev/null and b/apps/ios/SimpleX Snapshots/References/YourServersView-light-large.png differ diff --git a/apps/ios/SimpleX Snapshots/SnapshotTests.swift b/apps/ios/SimpleX Snapshots/SnapshotTests.swift new file mode 100644 index 0000000000..53a27c7615 --- /dev/null +++ b/apps/ios/SimpleX Snapshots/SnapshotTests.swift @@ -0,0 +1,236 @@ +import XCTest +import SwiftUI +import UIKit +@testable import SimpleX +@testable import SimpleXChat + +final class SnapshotTests: XCTestCase { + @MainActor + func testPreviews() throws { + NSTimeZone.default = try XCTUnwrap(TimeZone(identifier: "UTC")) + let env = ProcessInfo.processInfo.environment + let dir = URL(fileURLWithPath: env["SNAPSHOT_DIR"] ?? NSTemporaryDirectory() + "snapshots", isDirectory: true) + let size = env["SNAPSHOT_SIZE"] ?? "large" + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let windowScene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + ChatModel.shared.currentUser = User.sampleData + var index: [[String: Any]] = [] + for dark in [false, true] { + let theme = snapshotTheme(dark) + CurrentColors = theme + for scene in previewScenes() where scene.allSizes || size == "large" { + let themeName = dark ? "dark" : "light" + let file = "\(scene.name)-\(themeName)-\(size).png" + let image = try renderScene(scene, theme.toAppTheme(), dark: dark, in: windowScene) + try XCTUnwrap(image.pngData()).write(to: dir.appendingPathComponent(file)) + index.append([ + "scene": scene.name, + "theme": themeName, + "size": size, + "file": file, + "width": Int((image.size.width * image.scale).rounded()), + "height": Int((image.size.height * image.scale).rounded()), + "scale": image.scale, + "contentSizeCategory": UIApplication.shared.preferredContentSizeCategory.rawValue + ]) + } + } + let data = try JSONSerialization.data(withJSONObject: index, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: dir.appendingPathComponent("index-\(size).json")) + } +} + +private struct SnapshotScene { + var name: String + var height: CGFloat? = nil + var allSizes: Bool + var view: AnyView + + init(_ preview: P.Type, allSizes: Bool = false) { + name = String(describing: preview).replacingOccurrences(of: "_Previews", with: "") + self.allSizes = allSizes + view = AnyView(preview.previews) + } +} + +private func previewScenes() -> [SnapshotScene] { + [ + SnapshotScene(ActiveCallOverlay_Previews.self), + SnapshotScene(AddChannelView_Previews.self), + SnapshotScene(AddContactLearnMore_Previews.self), + SnapshotScene(AddGroupMembersView_Previews.self), + SnapshotScene(AddGroupView_Previews.self), + SnapshotScene(AdvancedNetworkSettings_Previews.self), + SnapshotScene(BadgesCheckOrderView_Previews.self), + SnapshotScene(BadgesHowItWorksView_Previews.self), + SnapshotScene(BadgesHowLongView_Previews.self), + SnapshotScene(BadgesRedeemCodeView_Previews.self), + SnapshotScene(BadgesSupportSimplexView_Previews.self), + SnapshotScene(CallSettings_Previews.self), + SnapshotScene(ChannelMembersView_Previews.self), + SnapshotScene(ChannelRelaysView_Previews.self), + SnapshotScene(ChatHelp_Previews.self), + SnapshotScene(ChatInfoImage_Previews.self), + SnapshotScene(ChatInfoToolbar_Previews.self), + SnapshotScene(ChatInfoView_Previews.self), + SnapshotScene(ChatItemForwardingView_Previews.self), + SnapshotScene(ChatItemInfoView_Previews.self), + SnapshotScene(ChatItemView_NonMsgContentDeleted_Previews.self, allSizes: true), + SnapshotScene(ChatItemView_Previews.self, allSizes: true), + SnapshotScene(ChatListNavLink_Previews.self), + SnapshotScene(ChatListView_Previews.self), + SnapshotScene(ChatPreviewView_Previews.self), + SnapshotScene(ChatView_Previews.self), + SnapshotScene(CIChatFeatureView_Previews.self, allSizes: true), + SnapshotScene(CIEventView_Previews.self, allSizes: true), + SnapshotScene(CIFeaturePreferenceView_Previews.self, allSizes: true), + SnapshotScene(CIFileView_Previews.self, allSizes: true), + SnapshotScene(CIGroupInvitationView_Previews.self, allSizes: true), + SnapshotScene(CIInvalidJSONView_Previews.self, allSizes: true), + SnapshotScene(CIMemberCreatedContactView_Previews.self, allSizes: true), + SnapshotScene(CIMetaView_Previews.self, allSizes: true), + SnapshotScene(CIVoiceView_Previews.self, allSizes: true), + SnapshotScene(ComposeVoiceView_Previews.self), + SnapshotScene(ConnectDesktopView_Previews.self), + SnapshotScene(ContactConnectionInfo_Previews.self), + SnapshotScene(ContactConnectionView_Previews.self), + SnapshotScene(ContactPreferencesView_Previews.self), + SnapshotScene(ContactRequestView_Previews.self), + SnapshotScene(ContextContactRequestActionsView_Previews.self), + SnapshotScene(ContextItemView_Previews.self), + SnapshotScene(ContextMemberContactActionsView_Previews.self), + SnapshotScene(ContextPendingMemberActionsView_Previews.self), + SnapshotScene(ContextProfilePickerView_Previews.self), + SnapshotScene(CreateProfile_Previews.self), + SnapshotScene(CreateSimpleXAddress_Previews.self), + SnapshotScene(CustomTimePicker_Previews.self), + SnapshotScene(DatabaseEncryptionView_Previews.self), + SnapshotScene(DatabaseErrorView_Previews.self), + SnapshotScene(DatabaseView_Previews.self), + SnapshotScene(DeletedItemView_Previews.self, allSizes: true), + SnapshotScene(DetermineWidth_Previews.self), + SnapshotScene(DeveloperView_Previews.self), + SnapshotScene(EmojiItemView_Previews.self, allSizes: true), + SnapshotScene(FramedCIVoiceView_Previews.self, allSizes: true), + SnapshotScene(FramedItemView_Deleted_Previews.self, allSizes: true), + SnapshotScene(FramedItemView_Edited_Previews.self, allSizes: true), + SnapshotScene(FramedItemView_Previews.self, allSizes: true), + SnapshotScene(GetStakeBanner_Previews.self), + SnapshotScene(GroupChatInfoView_Previews.self), + SnapshotScene(GroupLinkView_Previews.self), + SnapshotScene(GroupMemberInfoView_Previews.self), + SnapshotScene(GroupPreferencesView_Previews.self), + SnapshotScene(GroupProfileView_Previews.self), + SnapshotScene(GroupWelcomeView_Previews.self), + SnapshotScene(IncognitoHelp_Previews.self), + SnapshotScene(IncomingCallView_Previews.self), + SnapshotScene(IntegrityErrorItemView_Previews.self, allSizes: true), + SnapshotScene(LargeLinkPreview_Previews.self, allSizes: true), + SnapshotScene(LocalAuthView_Previews.self), + SnapshotScene(MarkdownHelp_Previews.self), + SnapshotScene(MarkedDeletedItemView_Previews.self, allSizes: true), + SnapshotScene(MemberAdmissionView_Previews.self), + SnapshotScene(MemberSupportChatToolbar_Previews.self), + SnapshotScene(MemberSupportView_Previews.self), + SnapshotScene(MigrateToDevice_Previews.self), + SnapshotScene(MigrateToGroupView_Previews.self), + SnapshotScene(MsgContentView_Previews.self, allSizes: true), + SnapshotScene(NativeTextEditor_Previews.self), + SnapshotScene(NetworkServersView_Previews.self), + SnapshotScene(NewChatMenuButton_Previews.self), + SnapshotScene(NewFeaturesView_Previews.self), + SnapshotScene(NewServerView_Previews.self), + SnapshotScene(NotificationsModeView_Previews.self), + SnapshotScene(NotificationsView_Previews.self), + SnapshotScene(OnboardingConditionsView_Previews.self), + SnapshotScene(OnboardingStepsView_Previews.self), + SnapshotScene(OneHandUICard_Previews.self), + SnapshotScene(OperatorView_Previews.self), + SnapshotScene(PasscodeEntry_Previews.self), + SnapshotScene(PasscodeViewView_Previews.self), + SnapshotScene(PreferencesView_Previews.self), + SnapshotScene(PrivacySettings_Previews.self), + SnapshotScene(ProfileImage_Previews.self), + SnapshotScene(ProfilePrivacyView_Previews.self), + SnapshotScene(ProtocolServerView_Previews.self), + SnapshotScene(QRCode_Previews.self), + SnapshotScene(RTCServers_Previews.self), + SnapshotScene(ScanCodeView_Previews.self), + SnapshotScene(ScanProtocolServer_Previews.self), + SnapshotScene(SecondaryChatView_Previews.self), + SnapshotScene(SendMessageView_Previews.self), + SnapshotScene(SetAppPasscodeView_Previews.self), + SnapshotScene(SetDeliveryReceiptsView_Previews.self), + SnapshotScene(SettingsView_Previews.self), + SnapshotScene(SimpleXInfo_Previews.self), + SnapshotScene(SmallLinkPreview_Previews.self), + SnapshotScene(SupportSimpleXBanner_Previews.self), + SnapshotScene(UserAddressLearnMore_Previews.self), + SnapshotScene(UserAddressView_Previews.self), + SnapshotScene(UserPicker_Previews.self), + SnapshotScene(VerifyCodeView_Previews.self), + SnapshotScene(VersionView_Previews.self), + SnapshotScene(WhySimpleX_Previews.self), + SnapshotScene(YourServersView_Previews.self) + ] +} + +private func snapshotTheme(_ dark: Bool) -> ThemeManager.ActiveTheme { + let base: DefaultTheme = dark ? .DARK : .LIGHT + return ThemeManager.ActiveTheme( + name: base.themeName, + base: base, + colors: (dark ? DarkColorPalette : LightColorPalette).clone(), + appColors: (dark ? DarkColorPaletteApp : LightColorPaletteApp).clone(), + wallpaper: AppWallpaper(background: nil, tint: nil, type: .empty) + ) +} + +@MainActor +private func renderScene(_ scene: SnapshotScene, _ theme: AppTheme, dark: Bool, in windowScene: UIWindowScene) throws -> UIImage { + let screen = windowScene.screen + let width = screen.bounds.width + let root = VStack(spacing: 0) { scene.view } + .environmentObject(ChatModel.shared) + .environmentObject(theme) + .frame(width: width, height: scene.height) + let host = UIHostingController(rootView: AnyView(root)) + if #available(iOS 16.4, *) { + host.safeAreaRegions = [] + } + let window = UIWindow(windowScene: windowScene) + window.overrideUserInterfaceStyle = dark ? .dark : .light + window.rootViewController = host + window.frame = CGRect(x: 0, y: 0, width: width, height: scene.height ?? screen.bounds.height) + window.isHidden = false + settle() + if scene.height == nil { + for _ in 0..<2 { + let fit = host.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let fill = host.sizeThatFits(in: screen.bounds.size) + window.frame = CGRect(x: 0, y: 0, width: width, height: ceil(max(fit.height, fill.height))) + settle() + } + } + window.endEditing(true) + removeAnimations(window.layer) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.preferredRange = .standard + format.opaque = true + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in + _ = window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + window.isHidden = true + window.rootViewController = nil + return image +} + +private func removeAnimations(_ layer: CALayer) { + layer.removeAllAnimations() + layer.sublayers?.forEach(removeAnimations) +} + +private func settle() { + RunLoop.main.run(until: Date().addingTimeInterval(0.6)) +} diff --git a/apps/ios/SimpleX Snapshots/check-previews.py b/apps/ios/SimpleX Snapshots/check-previews.py new file mode 100755 index 0000000000..b4cff6c09d --- /dev/null +++ b/apps/ios/SimpleX Snapshots/check-previews.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import re +import sys +from pathlib import Path + +EXCLUDED = { + "AppearanceSettings_Previews", + "BadgesChooseBadgeView_Previews", + "MigrateFromDevice_Previews", + "NewChatView_Previews", + "ServersSummaryView_Previews", + "TerminalView_Previews", + "UserProfilesView_Previews", +} + +tests = Path(__file__).with_name("SnapshotTests.swift") +ios = tests.parents[1] +listed = set(re.findall(r"SnapshotScene\((\w+)\.self", tests.read_text())) +errors = [] +for path in sorted(ios.rglob("*.swift")): + source = path.read_text() + name = path.relative_to(ios.parent.parent) + for preview in re.findall(r"^[ \t]*struct[ \t]+(\w+)[ \t]*:[ \t]*PreviewProvider\b", source, re.M): + if preview not in listed and preview not in EXCLUDED: + errors.append(f"{name}: {preview} is not in previewScenes() of {tests.name}") + if re.search(r"^[ \t]*#Preview\b", source, re.M): + errors.append(f"{name}: #Preview is not rendered, write a PreviewProvider and add it to previewScenes() of {tests.name}") +print("\n".join(errors) or f"{len(listed)} previews are rendered, {len(EXCLUDED)} are excluded") +sys.exit(1 if errors else 0) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a3c29d98df..c55946f1c8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -193,8 +193,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.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.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 */; }; @@ -268,6 +268,7 @@ E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5A0B0012F960000AAAA0001 /* YourNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A0B0022F960000AAAA0001 /* YourNetwork.swift */; }; E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0C12F9C000100270665 /* SnapshotTests.swift */; }; E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; @@ -285,6 +286,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CA059C9279559F40002BEB4; + remoteInfo = "SimpleX (iOS)"; + }; 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; @@ -591,8 +599,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.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.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 = ""; }; @@ -662,6 +670,8 @@ E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5A0B0022F960000AAAA0001 /* YourNetwork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YourNetwork.swift; sourceTree = ""; }; E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotTests.swift; sourceTree = ""; }; + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SimpleX Snapshots.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; E5C0BBEA2F82B45500EA7527 /* SharedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SharedAssets.xcassets; sourceTree = ""; }; @@ -752,6 +762,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C62F9C000100270665 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD422818589900503DA2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -769,8 +786,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -857,8 +874,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-FQI9ONxXS3x5DhsgnqtwJs.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.14-C2bYStpiFGU19PwEXn8BAE.a */, ); path = Libraries; sourceTree = ""; @@ -939,6 +956,7 @@ 5CDCAD462818589900503DA2 /* SimpleX NSE */, CEE723A82C3BD3D70009AE93 /* SimpleX SE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */, 5CE2BA692845308900EC33A6 /* SimpleXChat */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, @@ -968,6 +986,7 @@ children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, + E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */, 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */, CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */, @@ -984,6 +1003,14 @@ path = "Tests iOS"; sourceTree = ""; }; + E5AEC0C42F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXGroup; + children = ( + E5AEC0C12F9C000100270665 /* SnapshotTests.swift */, + ); + path = "SimpleX Snapshots"; + sourceTree = ""; + }; 5CA8D01B2AD9B076001FD661 /* RemoteAccess */ = { isa = PBXGroup; children = ( @@ -1364,6 +1391,24 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */ = { + isa = PBXNativeTarget; + buildConfigurationList = E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */; + buildPhases = ( + E5AEC0C52F9C000100270665 /* Sources */, + E5AEC0C62F9C000100270665 /* Frameworks */, + E5AEC0C72F9C000100270665 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */, + ); + name = "SimpleX Snapshots"; + productName = "SimpleX Snapshots"; + productReference = E5AEC0C32F9C000100270665 /* SimpleX Snapshots.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 5CDCAD442818589900503DA2 /* SimpleX NSE */ = { isa = PBXNativeTarget; buildConfigurationList = 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */; @@ -1451,6 +1496,10 @@ CEE723A62C3BD3D70009AE93 = { CreatedOnToolsVersion = 15.4; }; + E5AEC0C82F9C000100270665 = { + CreatedOnToolsVersion = 26.0; + TestTargetID = 5CA059C9279559F40002BEB4; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -1493,6 +1542,7 @@ targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, + E5AEC0C82F9C000100270665 /* SimpleX Snapshots */, 5CDCAD442818589900503DA2 /* SimpleX NSE */, CEE723A62C3BD3D70009AE93 /* SimpleX SE */, 5CE2BA672845308900EC33A6 /* SimpleXChat */, @@ -1521,6 +1571,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C72F9C000100270665 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD432818589900503DA2 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1793,6 +1850,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E5AEC0C52F9C000100270665 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E5AEC0C22F9C000100270665 /* SnapshotTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5CDCAD412818589900503DA2 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1846,6 +1911,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + E5AEC0CA2F9C000100270665 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; + targetProxy = E5AEC0C92F9C000100270665 /* PBXContainerItemProxy */; + }; 5CA059D9279559F40002BEB4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; @@ -2285,6 +2355,43 @@ }; name = Release; }; + E5AEC0CC2F9C000100270665 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + }; + name = Debug; + }; + E5AEC0CD2F9C000100270665 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.snapshots"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SimpleX.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SimpleX"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 5CDCAD4E2818589900503DA2 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2564,6 +2671,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E5AEC0CB2F9C000100270665 /* Build configuration list for PBXNativeTarget "SimpleX Snapshots" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5AEC0CC2F9C000100270665 /* Debug */, + E5AEC0CD2F9C000100270665 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5CDCAD502818589900503DA2 /* Build configuration list for PBXNativeTarget "SimpleX NSE" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme index 6a1d4192e6..6815864bf4 100644 --- a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:SimpleX.xcodeproj"> + + + + String? { case .receiptInvalid: NSLocalizedString("The store did not confirm this purchase.", comment: "alert message") case .testPurchase: NSLocalizedString("Sandbox purchases are not credited. Badges can only be bought in the App Store version of the app.", comment: "alert message") case .receiptUsed: NSLocalizedString("This purchase was already credited to a badge on another device or profile.", comment: "alert message") + case .transferLimited: NSLocalizedString("This purchase was moved to another device or profile recently.", comment: "alert message") case .paymentPending: NSLocalizedString("The payment has not been confirmed yet.", comment: "alert message") case .providerUnavailable: NSLocalizedString("The store could not be reached.", comment: "alert message") case .providerNotConfigured: NSLocalizedString("Badges cannot be bought in the app yet.", comment: "alert message") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1e2051ceab..36383f710c 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -23,6 +23,8 @@ public let CALL_RECONNECT_VERSION = 22 public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")! +public let previewDate = Date(timeIntervalSince1970: 1_736_936_100) + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -386,10 +388,49 @@ public struct BadgeState: Decodable, Hashable { public var alert: BadgeAlert? public var issueError: BadgeIssueError? public var nextWakeAt: Date? + public var subscription: BadgeSubscription? public var paidThroughText: String { badgeDateText(paidThrough) } } +public struct BadgeSubscription: Decodable, Hashable { + public var planMonths: Int + // nil until the service or the device's store has been asked + public var status: BadgeSubscriptionState? + // the store's reference: Apple's original transaction id, or a hash of Play's token + public var transactionRef: String? +} + +// the store's state as last observed; a newer core may know a state this version does not +public enum BadgeSubscriptionState: Decodable, Hashable { + case renewing + case cancelled + case billingRetry + case ended + case unknown + + // as /_badge subscription takes it, which accepts only the four known states + public var text: String { + switch self { + case .renewing: "renewing" + case .cancelled: "cancelled" + case .billingRetry: "billingRetry" + case .ended: "ended" + case .unknown: "unknown" + } + } + + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "renewing": self = .renewing + case "cancelled": self = .cancelled + case "billingRetry": self = .billingRetry + case "ended": self = .ended + default: self = .unknown + } + } +} + public struct BadgeIssueError: Decodable, Hashable { public var failedSince: Date public var lastAttemptAt: Date @@ -622,13 +663,47 @@ public struct BadgeAlertPrice: Codable, Hashable { public var currency: String } -public enum BadgeAlertKind: String, Codable, Hashable { +// a kind added by a newer core decodes, so the badge state still does, and shows no banner +public enum BadgeAlertKind: Codable, Hashable { case renewalApproaching case paymentIssue + case subscriptionCancelled case subscriptionEnded case prepaidEnding case supportEnded case issueFailed + case unknown(String) + + public var text: String { + switch self { + case .renewalApproaching: "renewalApproaching" + case .paymentIssue: "paymentIssue" + case .subscriptionCancelled: "subscriptionCancelled" + case .subscriptionEnded: "subscriptionEnded" + case .prepaidEnding: "prepaidEnding" + case .supportEnded: "supportEnded" + case .issueFailed: "issueFailed" + case let .unknown(s): s + } + } + + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "renewalApproaching": self = .renewalApproaching + case "paymentIssue": self = .paymentIssue + case "subscriptionCancelled": self = .subscriptionCancelled + case "subscriptionEnded": self = .subscriptionEnded + case "prepaidEnding": self = .prepaidEnding + case "supportEnded": self = .supportEnded + case "issueFailed": self = .issueFailed + case let s: self = .unknown(s) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(text) + } } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) @@ -1818,6 +1893,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group simplexLinks: fullPreferences.simplexLinks, reports: fullPreferences.reports, history: fullPreferences.history, + support: fullPreferences.support, signMessages: fullPreferences.signMessages, commands: fullPreferences.commands ) @@ -2617,8 +2693,8 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, userPreferences: Preferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData, - createdAt: .now, - updatedAt: .now, + createdAt: previewDate, + updatedAt: previewDate, contactGrpInvSent: false, chatTags: [], chatDeleted: false @@ -3216,7 +3292,7 @@ public enum MsgVerified: Decodable, Equatable, Hashable { switch self { case .sigMissing: return ( NSLocalizedString("Signature missing", comment: "alert title"), - NSLocalizedString("The channel required this message to be signed, but the signature is missing.", comment: "alert message") + NSLocalizedString("The message was sent before the channel started signing messages, or it is fake.", comment: "alert message") ) default: return nil } @@ -3472,10 +3548,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { if groupInfo.useRelays && !groupInfo.isOwner { return nil } let userRole = groupInfo.membership.memberRole if groupInfo.useRelays { - // TODO [relays]: for now owners can only set observer/member in channels. - // Restore the full Owner-excluded picker when moderator/admin promotion is supported: - // return GroupMemberRole.supportedRoles.filter { $0 <= userRole && $0 != .owner } - return [.observer, .member] + return [.observer, .member, .moderator] } else { return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index 50d6bf2938..ae5e1b46c7 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -62,3 +62,19 @@ The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages --- +## Badges: Store Purchases + +### GAP: A badge moves to another device or profile only once in a while, and is bought again only to continue the last purchase +**Source:** [Shared/Views/Badges/BadgeStore.swift](../Shared/Views/Badges/BadgeStore.swift) +`restorePurchases()` hands over `Transaction.currentEntitlements` and `Transaction.unfinished` as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. On the same install a lapsed subscriber can subscribe again only to the badge the active profile's newest purchase retired: core resumes that purchase on the new charge, as Apple keeps the original transaction id. The app's gate (`forSale`, comparing the original ids in `Transaction.all` with the purchase's `transactionRef`; the ids are read at the first load and again before each subscription purchase, and extended by every auto-renewable transaction the sweep, its updates or Restore sees and every subscription bought) shuts subscriptions for any other profile, device or badge, and for the rest of the run after a purchase that awaits approval, which StoreKit does not list. + +**REC:** Restore by a receipt code for Stripe and crypto purchases, and announce a limited move on the first Restore. + +### GAP: A device whose subscription Restore moved away still offers to buy it again +**Source:** [Shared/Views/Badges/BadgeStore.swift](../Shared/Views/Badges/BadgeStore.swift) +Once Restore moves a subscription to another device or profile, the service answers the old purchase key ended, and this device retires its badge. Its ledger tip is then the `transferOut` debit, so core knows the payment moved out (`purchaseMovedOut` in `Store/Badges.hs`), but no field of `BadgeState` says so: the newest purchase still reads as a subscription not shown, of its own tier, with the same `subscription.transactionRef`. So `resumableSubscription` returns that original id, `forSale` opens monthly and annual at that tier when it is the only original id in `priorSubscriptions`, and with nothing open (`canBuy`) the order screen's `payButton` is enabled. A charge Apple makes then keeps the original transaction id, whose payment the service has bound to the key Restore moved it to, so it funds that key; here `handOverStoreReceipt` finds the purchase moved out, answers with its old credential, the app finishes the transaction, and this profile shows no badge and no message. + +**REC:** After the first release, carry the move in the badge state, so `forSale` closes subscriptions for a purchase whose payment moved away. + +--- + diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index 2087d0dee3..00a5583ee1 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -290,10 +290,10 @@ Gradient card inviting the user to invest on Wefunder. Shown only when [`isInUS` | Where | Condition | Layout | |-------|-----------|--------| -| Chat list | rendered whenever [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L413) is, in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` | +| Chat list | rendered whenever [`chatListContent`](../../Shared/Views/ChatList/ChatListView.swift#L477) is, in the `List` after `OneHandUICard` and before the chats | `.padding(.vertical, 3)`, flipped for one-hand UI, `.zIndex(1)` | | Onboarding | below [`ConnectOnboardingView`](../../Shared/Views/NewChat/OnboardingCards.swift#L135) when `shouldShowOnboarding` | `.padding(.horizontal, 20)` (the onboarding cards' margin), `.padding(.bottom, 8)` | -The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the support-ended alert (`supportEnded`), the renewal-failure alert (`badgeIssueFailed`), the pitch, then the Wefunder banner. Each banner records itself in `ChatModel.chatListBanner` (`.badgeExpired`, `.badgeIssueFailed`, `.badgePitch`, `.getStake`) in its `onAppear`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(for:)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge`, false until `BadgeModel` holds the current user's state, so it cannot take the slot from a supporter whose badge loads a moment later. The onboarding placement applies the same `bannerSlotFree` check and records `.getStake`. +The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the badge alert banner, the pitch, then the Wefunder banner. The badge alert banner shows `bannerBadgeAlert`, the current profile's alert when its kind has a banner (support ended, renewal failed, subscription ended, payment problem, subscription cancelled, renewal soon; not prepaid ending or a kind from a newer core), and one `switch` over the kind at the banner site picks its title, subtitle and warning colour; its X leads to the same remind-later or dismiss alert for every kind. The renewal-failed subtitle is `renewalFailure`: the reason in `BadgeModel`'s `badgeState.issueError` (`BadgeIssueFailure.text`), or "Your badge could not be renewed." when the state carries none, as after an alert that arrives without a new state; the payment-problem subtitle is "The store could not charge your payment method.", not `issueError`, which in billing retry is a failed renewal request rather than the payment's reason. Each banner records itself in `ChatModel.chatListBanner` (`.badgeAlert`, `.badgePitch`, `.getStake`) in its `onAppear`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(for:)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge`, false until `BadgeModel` holds the current user's state, so it cannot take the slot from a supporter whose badge loads a moment later. The onboarding placement applies the same `bannerSlotFree` check and records `.getStake`. In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied to the enclosing `VStack` rather than to each child, so the banner stays below the pages in both toolbar modes. @@ -301,12 +301,12 @@ In the onboarding branch the `.scaleEffect` and `ThemedBackground` are applied t | Default | Set by | Effect | |---------|--------|--------| -| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L408) | the dismiss X appears from then on, while there are chats | +| `DEFAULT_GET_STAKE_BANNER_TAPPED` | [`openGetStake()`](../../Shared/Views/ChatList/ChatListView.swift#L472) | the dismiss X appears from then on, while there are chats | | `DEFAULT_GET_STAKE_BANNER_DISMISSED` | the dismiss X | hides the banner in both placements | | `DEFAULT_SUPPORTER_BANNER_TAPPED` | tapping the supporter pitch | the pitch's dismiss X appears from then on | | `DEFAULT_SUPPORTER_BANNER_SHOWN` | the pitch's dismiss X, through its "You can support SimpleX later in Settings." alert, and a successful code redemption | hides the pitch | -The two badge alert banners always offer the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. +The badge alert banner always offers the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. Both are in `hintDefaults`, so "Reset all hints" in the developer settings restores the banner. The X is never offered in the onboarding branch, so the banner cannot be dismissed before the user has a chat. 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 0cad378da6..401b7edd07 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 @@ -31,3 +31,6 @@ suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun unfinishedBadgePurchases(): List = emptyList() + +@Suppress("UNUSED_PARAMETER") +fun manageBadgeSubscription(productId: String) {} 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 85b9da1316..32c5a32f8e 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,5 +1,8 @@ package chat.simplex.app +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri import chat.simplex.common.platform.Log import chat.simplex.common.platform.androidAppContext import chat.simplex.common.platform.androidPlayStoreCountry @@ -9,6 +12,8 @@ import chat.simplex.common.views.helpers.withLongRunningApi import com.android.billingclient.api.* import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext const val hasPlatformStore = true @@ -45,6 +50,8 @@ fun loadPlayStoreCountry() { @Volatile private var badgeBillingClient: BillingClient? = null @Volatile private var badgeOffers: Map = emptyMap() @Volatile private var badgePurchase: CompletableDeferred? = null +// two callers connecting at once would each keep a client open, and Play could deliver a purchase event to each +private val badgeBillingConnection = Mutex() // offerToken is null for one-time products, which have no base plan to choose private class BadgeOffer( @@ -92,7 +99,7 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc try { val launched = withContext(Dispatchers.Main) { client.launchBillingFlow(activity, params) } if (launched.responseCode != BillingClient.BillingResponseCode.OK) { - throw BadgeStoreError.BillingError(launched.responseCode, launched.debugMessage) + throw badgePurchaseError(launched) } return purchase.await() } finally { @@ -100,10 +107,20 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc } } -// one-time purchases Play still holds: bought and not consumed, or awaiting a slow payment +// purchases Play still holds: one-time products bought and not consumed, subscriptions in a paid period, +// and either awaiting a slow payment suspend fun unfinishedBadgePurchases(): List { val client = connectedBadgeBillingClient() - val params = QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build() + val oneTime = queryBadgePurchases(client, BillingClient.ProductType.INAPP) + // a Play that cannot sell subscriptions holds none; any other failure must not read as no purchases + val subscriptions = + if (client.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode == BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED) emptyList() + else queryBadgePurchases(client, BillingClient.ProductType.SUBS) + return (oneTime + subscriptions).mapNotNull(::badgePurchaseOutcome) +} + +private suspend fun queryBadgePurchases(client: BillingClient, productType: String): List { + val params = QueryPurchasesParams.newBuilder().setProductType(productType).build() val queried = CompletableDeferred>() client.queryPurchasesAsync(params) { result, purchases -> if (result.responseCode == BillingClient.BillingResponseCode.OK) { @@ -112,7 +129,7 @@ suspend fun unfinishedBadgePurchases(): List { queried.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) } } - return queried.await().mapNotNull(::badgePurchaseOutcome) + return queried.await() } private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, purchases -> @@ -120,7 +137,7 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p if (pending == null) { // settled outside a purchase call, such as a slow payment completing if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcile(it) } } + withLongRunningApi { purchases.mapNotNull(::badgePurchaseOutcome).forEach { BadgeStore.reconcileListed(it) } } } return@PurchasesUpdatedListener } @@ -128,15 +145,24 @@ private val badgePurchasesUpdatedListener = PurchasesUpdatedListener { result, p result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null -> { val outcomes = purchases.mapNotNull(::badgePurchaseOutcome) pending.complete(outcomes.firstOrNull() ?: BadgePurchaseOutcome.Cancelled) - withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcile(it) } } + withLongRunningApi { outcomes.drop(1).forEach { BadgeStore.reconcileListed(it) } } } result.responseCode == BillingClient.BillingResponseCode.USER_CANCELED -> pending.complete(BadgePurchaseOutcome.Cancelled) else -> - pending.completeExceptionally(BadgeStoreError.BillingError(result.responseCode, result.debugMessage)) + pending.completeExceptionally(badgePurchaseError(result)) } } +private fun badgePurchaseError(result: BillingResult): BadgeStoreError = when (result.responseCode) { + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> BadgeStoreError.AlreadyOwned + BillingClient.BillingResponseCode.NETWORK_ERROR, + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> BadgeStoreError.StoreUnreachable + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> BadgeStoreError.NotOnSale + else -> BadgeStoreError.BillingError(result.responseCode, result.debugMessage) +} + private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = when (purchase.purchaseState) { Purchase.PurchaseState.PENDING -> BadgePurchaseOutcome.Pending(purchase.accountIdentifiers?.obfuscatedAccountId) Purchase.PurchaseState.PURCHASED -> BadgePurchaseOutcome.Purchased( @@ -144,14 +170,22 @@ private fun badgePurchaseOutcome(purchase: Purchase): BadgePurchaseOutcome? = wh token = purchase.purchaseToken, productId = purchase.products.firstOrNull() ?: "", invoiceId = purchase.accountIdentifiers?.obfuscatedAccountId, - acknowledged = purchase.isAcknowledged + acknowledged = purchase.isAcknowledged, + // Play answers false for a one-time product + autoRenewing = if (purchase.products.firstOrNull() in badgeOneTimeProductIds) null else purchase.isAutoRenewing ) ) else -> null } -private suspend fun connectedBadgeBillingClient(): BillingClient { - badgeBillingClient?.let { if (it.isReady) return it } +private suspend fun connectedBadgeBillingClient(): BillingClient = badgeBillingConnection.withLock { + badgeBillingClient?.let { replaced -> + if (replaced.isReady) return@withLock replaced + // ended, it no longer hears the result of a purchase launched on it, which the next sweep finds unfinished + badgePurchase?.completeExceptionally(BadgeStoreError.BillingError(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, "billing client replaced")) + replaced.endConnection() + badgeBillingClient = null + } val client = BillingClient.newBuilder(androidAppContext) .setListener(badgePurchasesUpdatedListener) .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) @@ -163,7 +197,6 @@ private suspend fun connectedBadgeBillingClient(): BillingClient { } override fun onBillingServiceDisconnected() { - badgeBillingClient = null connected.complete( BillingResult.newBuilder().setResponseCode(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED).build() ) @@ -175,7 +208,7 @@ private suspend fun connectedBadgeBillingClient(): BillingClient { throw BadgeStoreError.BillingError(result.responseCode, result.debugMessage) } badgeBillingClient = client - return client + client } private suspend fun queryBadgeProducts(client: BillingClient, ids: List, productType: String): List { @@ -210,7 +243,8 @@ private fun ProductDetails.badgeOffer(id: BadgeStoreProductId): BadgeOffer? { 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 } + // the base plan's own offer: a promotional offer on the same base plan has an offerId and other pricing phases + val offer = subscriptionOfferDetails?.firstOrNull { it.basePlanId == id.basePlanId && it.offerId == null } if (offer == null) { Log.w(TAG, "badgeOffer: ${id.productId} has no base plan ${id.basePlanId}, Play has ${subscriptionOfferDetails?.map { it.basePlanId }}") return null @@ -231,8 +265,8 @@ suspend fun acknowledgeBadgePurchase(receipt: BadgeStoreReceipt) { if (!receipt.acknowledged) acknowledge(connectedBadgeBillingClient(), receipt.token) } -// consumed if one-time so it can be bought again, else acknowledged, as Play refunds an unacknowledged purchase -// after 3 days; decided by product id, as after a restart there is no ProductDetails for the purchase +// consumed if one-time so it can be bought again; a subscription is acknowledged by the service when it credits it, +// and Play lists it until it ends. Decided by product id, as after a restart there is no ProductDetails for the purchase suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { val client = connectedBadgeBillingClient() if (receipt.productId in badgeOneTimeProductIds) { @@ -240,8 +274,15 @@ suspend fun finishBadgePurchase(receipt: BadgeStoreReceipt) { val params = ConsumeParams.newBuilder().setPurchaseToken(receipt.token).build() client.consumeAsync(params) { result, _ -> done.complete(result) } checkBillingResult(done.await()) - } else { - acknowledge(client, receipt.token) + } +} + +fun manageBadgeSubscription(productId: String) { + val page = Uri.parse("https://play.google.com/store/account/subscriptions?sku=$productId&package=${androidAppContext.packageName}") + try { + androidAppContext.startActivity(Intent(Intent.ACTION_VIEW, page).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, "manageBadgeSubscription: ${e.message}") } } 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 9093b1427b..4d05afd855 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 @@ -361,6 +361,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override suspend fun androidUnfinishedBadgePurchases(): List = unfinishedBadgePurchases() + override fun androidManageBadgeSubscription(productId: String) = manageBadgeSubscription(productId) + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { 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 2ba2d913f5..c178fa401a 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 @@ -130,7 +130,7 @@ object BadgeModel { this.rhId.value == rhId && this.userId.value == userId } -enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake } +enum class ChatListBanner { BadgeAlert, BadgePitch, GetStake } data class FileToOpen(val fileId: Long, val inChatList: Boolean) @@ -2330,11 +2330,54 @@ data class BadgeState( val willRenew: Boolean, val alert: BadgeAlert? = null, val issueError: BadgeIssueError? = null, - val nextWakeAt: Instant? = null + val nextWakeAt: Instant? = null, + val subscription: BadgeSubscription? = null ) { val paidThroughText: String get() = badgeDateText(paidThrough) } +@Serializable +data class BadgeSubscription( + val planMonths: Int, + // null until the service or the device's store has been asked + val status: BadgeSubscriptionState? = null, + // the store's reference: Apple's original transaction id, or a hash of Play's token + val transactionRef: String? = null +) + +// the store's state as last observed; a newer core may know a state this version does not +@Serializable(with = BadgeSubscriptionStateSerializer::class) +enum class BadgeSubscriptionState { + Renewing, + Cancelled, + BillingRetry, + Ended, + Unknown; + + // as /_badge subscription takes it, which accepts only the four known states + val text: String + get() = when (this) { + Renewing -> "renewing" + Cancelled -> "cancelled" + BillingRetry -> "billingRetry" + Ended -> "ended" + Unknown -> "unknown" + } +} + +object BadgeSubscriptionStateSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeSubscriptionState", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeSubscriptionState = + when (decoder.decodeString()) { + "renewing" -> BadgeSubscriptionState.Renewing + "cancelled" -> BadgeSubscriptionState.Cancelled + "billingRetry" -> BadgeSubscriptionState.BillingRetry + "ended" -> BadgeSubscriptionState.Ended + else -> BadgeSubscriptionState.Unknown + } + override fun serialize(encoder: Encoder, value: BadgeSubscriptionState) = encoder.encodeString(value.text) +} + @Serializable data class BadgeIssueError( val failedSince: Instant, @@ -2519,14 +2562,45 @@ data class BadgeAlert( @Serializable data class BadgeAlertPrice(val amount: Long, val currency: String) -@Serializable -enum class BadgeAlertKind { - @SerialName("renewalApproaching") RenewalApproaching, - @SerialName("paymentIssue") PaymentIssue, - @SerialName("subscriptionEnded") SubscriptionEnded, - @SerialName("prepaidEnding") PrepaidEnding, - @SerialName("supportEnded") SupportEnded, - @SerialName("issueFailed") IssueFailed +// a kind added by a newer core decodes, so the badge state still does, and shows no banner +@Serializable(with = BadgeAlertKindSerializer::class) +sealed class BadgeAlertKind { + object RenewalApproaching: BadgeAlertKind() + object PaymentIssue: BadgeAlertKind() + object SubscriptionCancelled: BadgeAlertKind() + object SubscriptionEnded: BadgeAlertKind() + object PrepaidEnding: BadgeAlertKind() + object SupportEnded: BadgeAlertKind() + object IssueFailed: BadgeAlertKind() + data class Unknown(val kind: String): BadgeAlertKind() + + val text: String + get() = when (this) { + is RenewalApproaching -> "renewalApproaching" + is PaymentIssue -> "paymentIssue" + is SubscriptionCancelled -> "subscriptionCancelled" + is SubscriptionEnded -> "subscriptionEnded" + is PrepaidEnding -> "prepaidEnding" + is SupportEnded -> "supportEnded" + is IssueFailed -> "issueFailed" + 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()) { + "renewalApproaching" -> BadgeAlertKind.RenewalApproaching + "paymentIssue" -> BadgeAlertKind.PaymentIssue + "subscriptionCancelled" -> BadgeAlertKind.SubscriptionCancelled + "subscriptionEnded" -> BadgeAlertKind.SubscriptionEnded + "prepaidEnding" -> BadgeAlertKind.PrepaidEnding + "supportEnded" -> BadgeAlertKind.SupportEnded + "issueFailed" -> BadgeAlertKind.IssueFailed + else -> BadgeAlertKind.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeAlertKind) = encoder.encodeString(value.text) } fun badgeDateText(date: Instant): String { @@ -3049,10 +3123,7 @@ data class GroupMember ( else if (groupInfo.useRelays && !groupInfo.isOwner) null else groupInfo.membership.memberRole.let { userRole -> if (groupInfo.useRelays) - // TODO [relays]: for now owners can only set observer/member in channels. - // Restore the full Owner-excluded picker when moderator/admin promotion is supported: - // GroupMemberRole.selectableRoles.filter { it <= userRole && it != GroupMemberRole.Owner } - listOf(GroupMemberRole.Observer, GroupMemberRole.Member) + listOf(GroupMemberRole.Observer, GroupMemberRole.Member, GroupMemberRole.Moderator) else GroupMemberRole.selectableRoles.filter { it <= userRole } } 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 a81a7185ef..565949ae7a 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 @@ -603,11 +603,12 @@ object ChatController { } // log = false because a store receipt is a bearer secret, like a badge code - it is in the command. - suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, payment: ServicePayment): BadgePurchaseResult { - val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, payment), log = false) + suspend fun apiPurchaseBadge(rh: Long?, userId: Long, echoedInvoiceId: String?, origin: StoreReceiptOrigin, payment: ServicePayment): BadgePurchaseResult { + val r = sendCmd(rh, CC.ApiPurchaseBadge(userId, echoedInvoiceId, origin, payment), log = false) return when { r is API.Result && r.res is CR.BadgeStateR -> BadgePurchaseResult.Held(r.res.user, r.res.badgeState, r.res.storePurchases) r is API.Result && r.res is CR.BadgeRedeemed -> BadgePurchaseResult.Credited(r.res.user.updateRemoteHostId(rh), r.res.badgeState) + r is API.Result && r.res is CR.StoreReceiptNotRecorded -> BadgePurchaseResult.NotRecorded r is API.Error -> BadgePurchaseResult.Failed(r.err) else -> { // the response type alone - it names a case or a JSON key, never the service's message @@ -617,6 +618,14 @@ object ChatController { } } + // the device store's view of a subscription it charges, which changes what is shown and alerted, and retires its badge once ended + suspend fun apiSetBadgeSubscriptionStatus(rh: Long?, status: BadgeSubscriptionState, payment: ServicePayment) { + val r = sendCmd(rh, CC.ApiSetBadgeSubscriptionStatus(status, payment), log = false) + if (r is API.Result && r.res is CR.CmdOk) return + if (r is API.Error) throw BadgeStoreError.ApiError(r.err) + throw Exception("apiSetBadgeSubscriptionStatus: unexpected ${r.responseType}") + } + suspend fun apiCreateBadgeInvoice(rh: Long?, userId: Long): String { val r = sendCmd(rh, CC.ApiCreateBadgeInvoice(userId)) if (r is API.Result && r.res is CR.BadgeInvoice) return r.res.invoiceId @@ -634,6 +643,8 @@ object ChatController { return generalGetString(if (purchase) MR.strings.badges_error_service_not_configured_purchase else MR.strings.badges_error_service_not_configured) is BadgeRedeemError.BadgeActive -> return generalGetString(if (purchase) MR.strings.badges_error_already_active_purchase else MR.strings.badges_error_already_active) + is BadgeRedeemError.StorePurchaseOpen -> + return generalGetString(if (purchase) MR.strings.badges_error_store_purchase_open_purchase else MR.strings.badges_error_store_purchase_open) is BadgeRedeemError.ServiceError -> badgeServiceErrorText(e.serviceError)?.let { return it } is BadgeRedeemError.InvalidResponse -> return String.format(generalGetString(MR.strings.badges_error_bad_service_response), e.message) is BadgeRedeemError.UnknownKeyIndex, is BadgeRedeemError.CredentialNotVerified -> return generalGetString(MR.strings.badges_error_credential_not_verified) @@ -4005,6 +4016,8 @@ data class OpenStorePurchase(val invoiceId: String? = null, val transactionRef: sealed class BadgePurchaseResult { class Held(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List): BadgePurchaseResult() class Credited(val user: User, val badgeState: BadgeState?): BadgePurchaseResult() + // listed, and no record here has it: another device's purchase on the same Google account + object NotRecorded: BadgePurchaseResult() // err is null for a response of an unexpected type, which is logged where it is received class Failed(val err: ChatError?): BadgePurchaseResult() } @@ -4189,7 +4202,8 @@ sealed class CC { class ApiStandaloneFileInfo(val url: String): CC() // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() - class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val payment: ServicePayment): CC() + class ApiPurchaseBadge(val userId: Long, val echoedInvoiceId: String?, val origin: StoreReceiptOrigin, val payment: ServicePayment): CC() + class ApiSetBadgeSubscriptionStatus(val status: BadgeSubscriptionState, val payment: ServicePayment): CC() class ApiCreateBadgeInvoice(val userId: Long): CC() class ApiGetBadgeState(val userId: Long): CC() class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() @@ -4416,7 +4430,8 @@ sealed class CC { is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}" is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" - is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""} ${json.encodeToString(payment)}" + is ApiPurchaseBadge -> "/_badge purchase $userId${echoedInvoiceId?.let { " invoice=$it" } ?: ""}${storeReceiptOriginParam(origin)} ${json.encodeToString(payment)}" + is ApiSetBadgeSubscriptionStatus -> "/_badge subscription ${status.text} ${json.encodeToString(payment)}" is ApiCreateBadgeInvoice -> "/_badge invoice $userId" is ApiGetBadgeState -> "/_badge state $userId" is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" @@ -4603,6 +4618,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" is ApiPurchaseBadge -> "apiPurchaseBadge" + is ApiSetBadgeSubscriptionStatus -> "apiSetBadgeSubscriptionStatus" is ApiCreateBadgeInvoice -> "apiCreateBadgeInvoice" is ApiGetBadgeState -> "apiGetBadgeState" is ApiGetBadgeLedger -> "apiGetBadgeLedger" @@ -4630,7 +4646,10 @@ sealed class CC { is TestStorageEncryption -> TestStorageEncryption(obfuscate(key)) // a code or a store receipt is a bearer secret until it is redeemed, and the terminal shows and copies cmdString is ApiRedeemBadgeCode -> ApiRedeemBadgeCode(userId, obfuscate(code)) - is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, when (payment) { + is ApiPurchaseBadge -> ApiPurchaseBadge(userId, echoedInvoiceId, origin, when (payment) { + is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) + }) + is ApiSetBadgeSubscriptionStatus -> ApiSetBadgeSubscriptionStatus(status, when (payment) { is ServicePayment.Google -> ServicePayment.Google(payment.productId, obfuscate(payment.token)) }) else -> this @@ -4672,16 +4691,32 @@ sealed class ServicePayment { @Serializable @SerialName("google") class Google(val productId: String, val token: String): ServicePayment() } +enum class StoreReceiptOrigin { + Purchased, + // found in the store's list rather than bought or restored here, so core records it only into a record it has + Listed, + // Restore: core claims it for this profile, moving it from another device or profile it was credited to + Restored +} + fun onOff(b: Boolean): String = if (b) "on" else "off" -// /_badge ack takes the kind in core's text encoding, not the JSON tag +// /_badge ack takes the kind in core's text encoding, not the JSON tag; an unknown kind has no banner to dismiss, so it is never acked private fun badgeAlertKindParam(kind: BadgeAlertKind): String = when (kind) { - BadgeAlertKind.RenewalApproaching -> "renewal_approaching" - BadgeAlertKind.PaymentIssue -> "payment_issue" - BadgeAlertKind.SubscriptionEnded -> "subscription_ended" - BadgeAlertKind.PrepaidEnding -> "prepaid_ending" - BadgeAlertKind.SupportEnded -> "support_ended" - BadgeAlertKind.IssueFailed -> "issue_failed" + is BadgeAlertKind.RenewalApproaching -> "renewal_approaching" + is BadgeAlertKind.PaymentIssue -> "payment_issue" + is BadgeAlertKind.SubscriptionCancelled -> "subscription_cancelled" + is BadgeAlertKind.SubscriptionEnded -> "subscription_ended" + is BadgeAlertKind.PrepaidEnding -> "prepaid_ending" + is BadgeAlertKind.SupportEnded -> "support_ended" + is BadgeAlertKind.IssueFailed -> "issue_failed" + is BadgeAlertKind.Unknown -> kind.kind +} + +private fun storeReceiptOriginParam(origin: StoreReceiptOrigin): String = when (origin) { + StoreReceiptOrigin.Purchased -> "" + StoreReceiptOrigin.Listed -> " listed" + StoreReceiptOrigin.Restored -> " restored" } @Serializable @@ -7012,6 +7047,7 @@ sealed class CR { // 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, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeInvoice") class BadgeInvoice(val user: UserRef, val invoiceId: String): CR() + @Serializable @SerialName("storeReceiptNotRecorded") class StoreReceiptNotRecorded(val user: UserRef): CR() @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?, val storePurchases: List = emptyList()): CR() @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @@ -7208,6 +7244,7 @@ sealed class CR { is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" is BadgeInvoice -> "badgeInvoice" + is StoreReceiptNotRecorded -> "storeReceiptNotRecorded" is BadgeStateR -> "badgeState" is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" @@ -7421,6 +7458,7 @@ sealed class CR { is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") is BadgeInvoice -> withUser(user, invoiceId) + is StoreReceiptNotRecorded -> withUser(user, noDetails()) is BadgeStateR -> withUser(user, "${json.encodeToString(badgeState)}\nstorePurchases: ${json.encodeToString(storePurchases)}") is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) @@ -7486,6 +7524,7 @@ sealed class BadgeRedeemError { is InvalidReceipt -> "invalidReceipt" is ServiceNotConfigured -> "serviceNotConfigured" is BadgeActive -> "badgeActive" + is StorePurchaseOpen -> "storePurchaseOpen" is ServiceError -> "serviceError ${serviceError.text}" is InvalidResponse -> "invalidResponse $message" is UnknownKeyIndex -> "unknownKeyIndex" @@ -7495,6 +7534,7 @@ sealed class BadgeRedeemError { @Serializable @SerialName("invalidReceipt") object InvalidReceipt : BadgeRedeemError() @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() @Serializable @SerialName("badgeActive") object BadgeActive : BadgeRedeemError() + @Serializable @SerialName("storePurchaseOpen") object StorePurchaseOpen : BadgeRedeemError() @Serializable @SerialName("serviceError") class ServiceError(val serviceError: BadgeServiceErrorCode) : BadgeRedeemError() @Serializable @SerialName("invalidResponse") class InvalidResponse(val message: String) : BadgeRedeemError() @Serializable @SerialName("unknownKeyIndex") object UnknownKeyIndex : BadgeRedeemError() @@ -7522,6 +7562,7 @@ sealed class BadgeServiceErrorCode { object ReceiptInvalid: BadgeServiceErrorCode() object ReceiptUsed: BadgeServiceErrorCode() object TestPurchase: BadgeServiceErrorCode() + object TransferLimited: BadgeServiceErrorCode() object Internal: BadgeServiceErrorCode() data class Unknown(val code: String): BadgeServiceErrorCode() @@ -7545,6 +7586,7 @@ sealed class BadgeServiceErrorCode { is ReceiptInvalid -> "receipt_invalid" is ReceiptUsed -> "receipt_used" is TestPurchase -> "test_purchase" + is TransferLimited -> "transfer_limited" is Internal -> "internal" is Unknown -> code } @@ -7561,6 +7603,7 @@ fun badgeServiceErrorText(code: BadgeServiceErrorCode): String? = when (code) { is BadgeServiceErrorCode.ReceiptInvalid -> generalGetString(MR.strings.badges_error_receipt_invalid) is BadgeServiceErrorCode.TestPurchase -> generalGetString(MR.strings.badges_error_test_purchase) is BadgeServiceErrorCode.ReceiptUsed -> generalGetString(MR.strings.badges_error_receipt_used) + is BadgeServiceErrorCode.TransferLimited -> generalGetString(MR.strings.badges_error_transfer_limited) is BadgeServiceErrorCode.PaymentPending -> generalGetString(MR.strings.badges_error_payment_pending) is BadgeServiceErrorCode.ProviderUnavailable -> generalGetString(MR.strings.badges_error_provider_unavailable) is BadgeServiceErrorCode.ProviderNotConfigured -> generalGetString(MR.strings.badges_error_provider_not_configured) @@ -7590,6 +7633,7 @@ object BadgeServiceErrorCodeSerializer : KSerializer { "receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid "receipt_used" -> BadgeServiceErrorCode.ReceiptUsed "test_purchase" -> BadgeServiceErrorCode.TestPurchase + "transfer_limited" -> BadgeServiceErrorCode.TransferLimited "internal" -> BadgeServiceErrorCode.Internal else -> BadgeServiceErrorCode.Unknown(v) } 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 f4e902b967..85b5fcd7f2 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 @@ -48,6 +48,7 @@ interface PlatformInterface { suspend fun androidAcknowledgeBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidFinishBadgePurchase(receipt: BadgeStoreReceipt) {} suspend fun androidUnfinishedBadgePurchases(): List = emptyList() + fun androidManageBadgeSubscription(productId: String) {} 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 index ed971fc1e5..e0d4af9d48 100644 --- 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 @@ -42,16 +42,12 @@ val badgeStoreProductIds: List = BadgeLevel.entries.flatMap BadgePeriod.entries.map { badgeStoreProductId(level, it) } } -// the only products sent to the badge service: nothing delivers a subscription yet +val badgeProductIds: Set = badgeStoreProductIds.map { it.productId }.toSet() + +// a one-time product is acknowledged when core holds it and consumed when finished; a subscription is +// neither, as the service acknowledges it when it credits it, and Play refunds it if it never does val badgeOneTimeProductIds: Set = badgeStoreProductIds.filter { it.basePlanId == null }.map { it.productId }.toSet() -// subscriptions are for sale once renewals are delivered -val badgePeriodsForSale: List = listOf(BadgePeriod.OneMonth) - -// A subscription's id only: core mints a one-time purchase's, and a subscription is never sent to core. -// Sent to Play as obfuscatedAccountId and echoed back on the purchase. -fun newBadgeInvoiceId(): String = UUID.randomUUID().toString() - // the page's app flag rides in the fragment, which never reaches the service fun badgePageUrl(linkReturns: Boolean): String = if (linkReturns) "https://badges.simplex.chat/#/tier?app=true" @@ -95,7 +91,9 @@ data class BadgeStoreReceipt( val token: String, val productId: String, val invoiceId: String?, - val acknowledged: Boolean = false + val acknowledged: Boolean = false, + // a subscription's, as Play lists it; null for a one-time product + val autoRenewing: Boolean? = null ) // TODO [badges] Play Billing has no offline product configuration. Set to true to price the screens @@ -132,6 +130,9 @@ enum class BadgePurchaseState { sealed class BadgeStoreError: Exception() { class ProductUnavailable(val productId: String): BadgeStoreError() class BillingError(val responseCode: Int, val debugMessage: String): BadgeStoreError() + object AlreadyOwned: BadgeStoreError() + object StoreUnreachable: BadgeStoreError() + object NotOnSale: BadgeStoreError() object StoreUnavailable: BadgeStoreError() object NoActiveProfile: BadgeStoreError() class ApiError(val err: ChatError): BadgeStoreError() @@ -140,12 +141,23 @@ sealed class BadgeStoreError: Exception() { get() = when (this) { is ProductUnavailable -> "productUnavailable(productId: $productId)" is BillingError -> "billingError(responseCode: $responseCode, $debugMessage)" + is AlreadyOwned -> "alreadyOwned" + is StoreUnreachable -> "storeUnreachable" + is NotOnSale -> "notOnSale" is StoreUnavailable -> "storeUnavailable" is NoActiveProfile -> "noActiveProfile" is ApiError -> "apiError(${err.string})" } } +// the sentences iOS shows for the same store failures, in redeemErrorText +fun badgeStoreErrorText(e: Exception): String? = when (e) { + is BadgeStoreError.AlreadyOwned -> generalGetString(MR.strings.badges_error_already_owned) + is BadgeStoreError.StoreUnreachable -> generalGetString(MR.strings.badges_error_store_unreachable) + is BadgeStoreError.NotOnSale -> generalGetString(MR.strings.badges_error_not_on_sale) + else -> null +} + object BadgeStore { private enum class LoadState { NotLoaded, Loading, Loaded, Failed } @@ -191,6 +203,10 @@ object BadgeStore { return if (rhId == chatModel.remoteHostId() && readFor == userId) purchases else emptyList() } + // Play gives a subscription bought again a new token, and refuses to sell one the account still has + @Suppress("UNUSED_PARAMETER") + fun forSale(period: BadgePeriod): Boolean = true + fun price(level: BadgeLevel, period: BadgePeriod, compact: Boolean = true): BadgePrice = when (state.value) { LoadState.NotLoaded, LoadState.Loading -> BadgePrice.Loading LoadState.Loaded, LoadState.Failed -> { @@ -230,16 +246,10 @@ object BadgeStore { } } - // A one-time purchase has its core record before the store charges, and every store outcome reaches it. + // A purchase has its core record before the store charges, and every store outcome reaches it. suspend fun purchase(level: BadgeLevel, period: BadgePeriod): BadgePurchaseOutcome { val id = badgeStoreProductId(level, period) if (!products.value.containsKey(id)) throw BadgeStoreError.ProductUnavailable(id.productId) - // a subscription is never sent to core, so nothing would ever finish it later - if (id.productId !in badgeOneTimeProductIds) { - val outcome = storePurchase(id, newBadgeInvoiceId()) - if (outcome is BadgePurchaseOutcome.Purchased) finish(outcome.receipt) - return outcome - } val rhId = chatModel.remoteHostId() val userId = chatModel.currentUser.value?.userId ?: throw BadgeStoreError.NoActiveProfile val invoiceId = chatModel.controller.apiCreateBadgeInvoice(rhId, userId) @@ -248,7 +258,7 @@ object BadgeStore { try { val outcome = storePurchase(id, invoiceId) if (outcome is BadgePurchaseOutcome.Purchased) { - handOver(outcome.receipt) + handOver(outcome.receipt, StoreReceiptOrigin.Purchased) } return outcome } finally { @@ -260,7 +270,8 @@ object BadgeStore { val outcome = if (useBadgeTestProducts) { BadgePurchaseOutcome.Purchased( BadgeStoreReceipt( - token = "test-${UUID.randomUUID()}", + // the dev service's mock reads a subscription's plan from its token, as Play's real ones name none + token = if (id.basePlanId != null) "test.${id.basePlanId}.${UUID.randomUUID()}" else "test-${UUID.randomUUID()}", productId = id.productId, invoiceId = invoiceId ) @@ -274,13 +285,14 @@ object BadgeStore { // Core holds the receipt and credits it; the purchase stays unfinished until core answers it credited // or refused, as an unfinished purchase is what the store re-delivers if anything is lost on the way. - private suspend fun handOver(receipt: BadgeStoreReceipt) { + private suspend fun handOver(receipt: BadgeStoreReceipt, origin: StoreReceiptOrigin) { val rhId = chatModel.remoteHostId() val userId = chatModel.currentUser.value?.userId ?: return - when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, ServicePayment.Google(receipt.productId, receipt.token))) { + when (val r = chatModel.controller.apiPurchaseBadge(rhId, userId, receipt.invoiceId, origin, ServicePayment.Google(receipt.productId, receipt.token))) { is BadgePurchaseResult.Held -> { - // core holds it durably now, so Play must not refund it after three days unacknowledged - acknowledge(receipt) + // core holds it durably now, so Play must not refund it after three days unacknowledged; + // a subscription is acknowledged by the service when it credits it + if (receipt.productId in badgeOneTimeProductIds) acknowledge(receipt) withContext(Dispatchers.Main) { // the answer is the owner's, which may be another profile and a hidden one if (chatModel.controller.activeUser(rhId, r.user)) { @@ -299,11 +311,16 @@ object BadgeStore { } finish(receipt) } + is BadgePurchaseResult.NotRecorded -> { + // left unfinished, for the device that bought it, or for Restore to claim it here + } // a refusal is announced from core's event for it, so only an unexpected answer reaches the buyer is BadgePurchaseResult.Failed -> if (badgeReceiptRefused(r.err)) { Log.e(TAG, "BadgeStore.handOver: ${r.err?.string}") finish(receipt) + // Restore found this purchase, so it says why it was refused rather than that there was nothing to restore + if (origin == StoreReceiptOrigin.Restored && r.err != null) throw BadgeStoreError.ApiError(r.err) } else { throw r.err?.let(BadgeStoreError::ApiError) ?: Exception("apiPurchaseBadge: unexpected response") } @@ -358,7 +375,7 @@ object BadgeStore { val purchases = platform.androidUnfinishedBadgePurchases() // Play lists a purchase awaiting payment, so unlike on iOS the waiting state is re-found here withContext(Dispatchers.Main) { waitingForApproval.value = purchases.mapNotNull { (it as? BadgePurchaseOutcome.Pending)?.invoiceId }.toSet() } - purchases.forEach { reconcile(it) } + purchases.forEach { reconcileListed(it) } } catch (e: Exception) { if (e is CancellationException) throw e Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}") @@ -368,35 +385,110 @@ object BadgeStore { } finally { withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true } } + reportSubscriptionStates() } - suspend fun reconcile(outcome: BadgePurchaseOutcome) { + // Play lists a Google account's purchases on every device signed into it, so these are handed over as listed + suspend fun reconcileListed(outcome: BadgePurchaseOutcome) { + // the purchase stays unfinished, so the next sweep hands it over again + try { + reconcile(outcome, StoreReceiptOrigin.Listed) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") + withContext(Dispatchers.Main) { + if (!sweepFailureShown) { + sweepFailureShown = true + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_purchase_error), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else badgeStoreErrorText(e) ?: "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + } + } + } + + private suspend fun reconcile(outcome: BadgePurchaseOutcome, origin: StoreReceiptOrigin) { when (outcome) { is BadgePurchaseOutcome.Purchased -> - if (outcome.receipt.productId !in badgeOneTimeProductIds) finish(outcome.receipt) - // the purchase stays unfinished, so the next sweep hands it over again - else try { - handOver(outcome.receipt) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e(TAG, "BadgeStore.reconcile: ${e.stackTraceToString()}") - withContext(Dispatchers.Main) { - if (!sweepFailureShown) { - sweepFailureShown = true - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.badges_purchase_error), - text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) - else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" - ) - } - } - } + if (outcome.receipt.productId !in badgeProductIds) finish(outcome.receipt) + else handOver(outcome.receipt, origin) is BadgePurchaseOutcome.Pending -> if (outcome.invoiceId != null) withContext(Dispatchers.Main) { waitingForApproval.value += outcome.invoiceId } is BadgePurchaseOutcome.Cancelled -> {} } } + // The service asks the store only once the paid period ends, so a cancellation or a failed charge before + // then is seen only here. Play lists no subscription on hold or expired, so it tells renewing or cancelled only. + suspend fun reportSubscriptionStates() { + // the store's purchases are this device's, so they are told to its own core + if (chatModel.remoteHostId() != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return + try { + val purchases = platform.androidUnfinishedBadgePurchases() + if (purchases.none { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.autoRenewing != null }) return + load() + for (outcome in purchases) { + val receipt = (outcome as? BadgePurchaseOutcome.Purchased)?.receipt ?: continue + val state = when (receipt.autoRenewing) { + true -> BadgeSubscriptionState.Renewing + false -> BadgeSubscriptionState.Cancelled + null -> null + } + if (state != null) { + chatModel.controller.apiSetBadgeSubscriptionStatus(null, state, ServicePayment.Google(receipt.productId, receipt.token)) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.reportSubscriptionStates: ${e.message}") + } + } + + // Play's page opens over the app, so the foreground sweep re-reads the subscription on return + fun manageSubscriptions(badgeType: BadgeType) { + val level = BadgeLevel.entries.firstOrNull { it.badgeType == badgeType } ?: return + platform.androidManageBadgeSubscription(badgeStoreProductId(level, BadgePeriod.Monthly).productId) + } + + // Hands over as restored whatever the store lists, so this profile claims it, also from another install or profile. + // Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards. + suspend fun restorePurchases(): Boolean { + var handOverError: Exception? = null + val purchases = if (useBadgeTestProducts) emptyList() else platform.androidUnfinishedBadgePurchases() + for (outcome in purchases) { + try { + reconcile(outcome, StoreReceiptOrigin.Restored) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(TAG, "BadgeStore.restorePurchases: ${e.stackTraceToString()}") + if (handOverError == null) handOverError = e + } + } + reportSubscriptionStates() + val rhId = chatModel.remoteHostId() + chatModel.controller.loadBadgeState(rhId) + val restored = withContext(Dispatchers.Main) { + val userId = chatModel.currentUser.value?.userId + val badgeShown = BadgeModel.isCurrent(rhId, userId) && BadgeModel.badgeState.value?.shown == true + badgeShown || purchaseState(userId) != null + } + if (!restored && handOverError != null) throw handOverError + return restored + } + + fun subscriptionPrice(badgeType: BadgeType, planMonths: Int): String? { + val period = when (planMonths) { + 1 -> BadgePeriod.Monthly + 12 -> BadgePeriod.Annual + else -> null + } + val level = BadgeLevel.entries.firstOrNull { it.badgeType == badgeType } + if (period == null || level == null) return null + return products.value[badgeStoreProductId(level, period)]?.displayPrice + } + private suspend fun acknowledge(receipt: BadgeStoreReceipt) { try { if (!useBadgeTestProducts) platform.androidAcknowledgeBadgePurchase(receipt) @@ -441,7 +533,11 @@ private fun compactPrice(product: BadgeProduct): String { // the codes core refuses a receipt with for good, after which the store may stop re-delivering it; // a sweep that hands over a receipt refused while the app was not running learns it only from this answer private fun badgeReceiptRefused(err: ChatError?): Boolean { - val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError - val code = (redeemError as? BadgeRedeemError.ServiceError)?.serviceError - return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase + val code = badgeServiceErrorCode(err) + return code is BadgeServiceErrorCode.ReceiptInvalid || code is BadgeServiceErrorCode.ReceiptUsed || code is BadgeServiceErrorCode.TestPurchase || code is BadgeServiceErrorCode.TransferLimited +} + +private fun badgeServiceErrorCode(err: ChatError?): BadgeServiceErrorCode? { + val redeemError = ((err as? ChatError.ChatErrorChat)?.errorType as? ChatErrorType.CEBadgeRedeemError)?.badgeRedeemError + return (redeemError as? BadgeRedeemError.ServiceError)?.serviceError } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt index fbb3d1a017..ce3a775efb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesCheckOrderView.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.badges import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -17,6 +18,7 @@ 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.usersettings.networkAndServers.ConditionsTextView import chat.simplex.res.* @Composable @@ -55,15 +57,49 @@ fun BadgesCheckOrderView(level: BadgeLevel, period: BadgePeriod, modalManager: M OrderRow(MR.strings.badges_order_total, period.priceText(BadgeStore.price(level, period, compact = false))) } + val price = BadgeStore.price(level, period, compact = false) + if (price is BadgePrice.Price) { + when (period) { + BadgePeriod.Monthly -> Footnote(String.format(stringResource(MR.strings.badges_subscription_disclosure_monthly), price.price)) + BadgePeriod.Annual -> Footnote(String.format(stringResource(MR.strings.badges_subscription_disclosure_annual), price.price)) + BadgePeriod.OneMonth -> {} + } + } + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally) { PayButton(level, period, purchasing) BadgeBillingFooter(period) + Text( + stringResource(MR.strings.badges_privacy_policy), + Modifier.clickable { modalManager.showModal { BadgeConditionsView() } }, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.primary + ) } } } +@Composable +private fun BadgeConditionsView() { + ColumnWithScrollBar(Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING)) { + AppBarTitle(stringResource(MR.strings.badges_privacy_policy), enableAlphaChanges = false, withPadding = false, bottomPadding = DEFAULT_PADDING) + Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF)) { + ConditionsTextView(chatModel.remoteHostId()) + } + } +} + +@Composable +private fun Footnote(text: String) { + Text( + text, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) +} + @Composable private fun OrderRow(title: StringResource, value: String) { Row(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { @@ -99,9 +135,48 @@ private fun purchase(level: BadgeLevel, period: BadgePeriod, purchasing: Mutable purchasing.value = false AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.badges_purchase_error), - text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) - else "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + text = when (e) { + is BadgeStoreError.ApiError -> chatModel.controller.redeemErrorText(e.err, purchase = true) + else -> badgeStoreErrorText(e) ?: "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + } ) } } } + +// the store rows act on this device's store, which a profile on a remote host cannot use +val badgeStoreLane: Boolean get() = badgeStoreAvailable && chatModel.remoteHostId() == null + +@Composable +fun RestorePurchasesButton() { + val restoring = remember { mutableStateOf(false) } + Text( + stringResource(MR.strings.badges_restore_purchases), + Modifier.clickable(enabled = !restoring.value) { restoreBadgePurchases(restoring) }, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.primary + ) +} + +fun restoreBadgePurchases(restoring: MutableState) { + restoring.value = true + withLongRunningApi { + try { + val found = BadgeStore.restorePurchases() + if (!found) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_nothing_to_restore), + text = generalGetString(MR.strings.badges_nothing_to_restore_desc) + ) + } + } catch (e: Exception) { + Log.e(TAG, "RestorePurchasesButton: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_restore_failed), + text = if (e is BadgeStoreError.ApiError) chatModel.controller.redeemErrorText(e.err, purchase = true) + else badgeStoreErrorText(e) ?: "${generalGetString(MR.strings.error_prefix)}: ${e.message ?: e}" + ) + } + restoring.value = false + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt index 8b58049276..db1586853a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowLongView.kt @@ -75,7 +75,9 @@ enum class BadgePeriod { @Composable fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDepth: Int) { - var selectedPeriod by remember { mutableStateOf(if (BadgePeriod.Monthly in badgePeriodsForSale) BadgePeriod.Monthly else BadgePeriod.OneMonth) } + var chosenPeriod by remember { mutableStateOf(null) } + // monthly once subscriptions turn out to be for sale, unless a period was chosen before that + val selectedPeriod = chosenPeriod ?: (if (BadgeStore.forSale(BadgePeriod.Monthly)) BadgePeriod.Monthly else BadgePeriod.OneMonth) LaunchedEffect(Unit) { BadgeStore.load() } CloseWhenSupportGivesWay(modalManager, unwindToDepth) @@ -114,14 +116,14 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDep horizontalArrangement = Arrangement.spacedBy(12.dp) ) { BadgePeriod.entries.forEach { period -> - PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(level, period, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { chosenPeriod = it } } } Spacer(Modifier.weight(1f).heightIn(min = 8.dp)) Column(horizontalAlignment = Alignment.CenterHorizontally) { - ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) + ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) { chosenPeriod = it } BadgeBillingFooter(selectedPeriod) } } @@ -145,7 +147,7 @@ fun BadgeBillingFooter(period: BadgePeriod) { @Composable private fun PeriodCard(level: BadgeLevel, period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { val isSelected = period == selectedPeriod - val forSale = period in badgePeriodsForSale + val forSale = BadgeStore.forSale(period) val textColor = if (forSale) Color.Unspecified else MaterialTheme.colors.secondary 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. @@ -186,12 +188,13 @@ private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? = if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null @Composable -private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) { +private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int, choosePeriod: (BadgePeriod) -> Unit) { 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 = { + choosePeriod(selectedPeriod) modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) } } ) 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 c0e1c968b9..da1a656af9 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 @@ -63,24 +63,26 @@ fun BadgesSupportSimplexView(modalManager: ModalManager, unwindToDepth: Int) { Spacer(Modifier.weight(1f)) - // In-app purchase is not released: the badge is chosen on the site, and the code redeemed - // here. Revert this commit to restore the block below. - // Column(horizontalAlignment = Alignment.CenterHorizontally) { - // if (badgeStoreAvailable) { - // ChooseLevelButton(modalManager, unwindToDepth) - // if (badgeBrowserAllowed()) { - // BuyInBrowserTextButton() - // } else { - // RedeemCodeButton(modalManager, unwindToDepth) - // } - // } else { - // BuyInBrowserButton(modalManager, unwindToDepth) - // RedeemCodeButton(modalManager, unwindToDepth) - // } - // } Column(horizontalAlignment = Alignment.CenterHorizontally) { - ChooseBadgeOnSiteButton(modalManager, unwindToDepth) - RedeemCodeButton(modalManager, unwindToDepth) + if (badgeStoreAvailable) { + ChooseLevelButton(modalManager, unwindToDepth) + if (badgeBrowserAllowed()) { + BuyInBrowserTextButton() + } else { + RedeemCodeButton(modalManager, unwindToDepth) + } + RestorePurchasesButton() + Text( + stringResource(MR.strings.badges_restore_purchases_footnote), + Modifier.padding(top = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } else { + BuyInBrowserButton(modalManager, unwindToDepth) + RedeemCodeButton(modalManager, unwindToDepth) + } } } } @@ -140,25 +142,6 @@ private fun BuyInBrowserTextButton() { ) } -// In-app purchase is not released: delete with the commented block above to restore it. -@Composable -private fun ChooseBadgeOnSiteButton(modalManager: ModalManager, unwindToDepth: Int) { - val uriHandler = LocalUriHandler.current - 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_badge_title, - onboarding = null, - onclick = { - val linkReturns = appLinkSchemeRegistered() - uriHandler.openUriCatching(badgePageUrl(linkReturns)) - // with no scheme to bring the code back, the code is pasted into this screen, opened beside the browser - if (!linkReturns && !modalManager.hasModalOpen(ModalViewId.BADGE_REDEEM_CODE)) { - modalManager.showModal(id = ModalViewId.BADGE_REDEEM_CODE) { BadgesRedeemCodeView(modalManager, unwindToDepth) } - } - } - ) -} - @Composable private fun BuyInBrowserButton(modalManager: ModalManager, unwindToDepth: Int) { val uriHandler = LocalUriHandler.current 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 index f6f6ecb12d..a295abd0aa 100644 --- 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 @@ -7,6 +7,7 @@ import SectionTextFooter import SectionView import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -22,6 +23,8 @@ 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.BadgeSubscriptionState +import chat.simplex.common.model.BadgeType import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.ColumnWithScrollBar @@ -35,6 +38,7 @@ import chat.simplex.common.views.helpers.openVerifiedSimplexUri import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.res.* +import kotlinx.datetime.Clock @Composable fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { @@ -45,11 +49,67 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { BadgeSummary(badgeState) } SectionSpacer() - SectionView(stringResource(MR.strings.badges_ends)) { - Text(badgeState.paidThroughText, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 12.dp)) + val subscription = badgeState.subscription + if (subscription != null) { + val ended = badgeState.paidThrough <= Clock.System.now() + val date = badgeState.paidThroughText + when (subscription.status) { + BadgeSubscriptionState.BillingRetry -> + SubscriptionSection( + badgeState.badgeType, + footer = stringResource(if (badgeStoreLane) MR.strings.badges_payment_problem_footer else MR.strings.badges_payment_problem_footer_store) + ) { + Icon(painterResource(MR.images.ic_warning), null, tint = Color.Red) + Spacer(Modifier.width(6.dp)) + Text(stringResource(MR.strings.badges_payment_problem)) + } + BadgeSubscriptionState.Cancelled -> + if (!ended) { + SubscriptionSection(badgeState.badgeType, footer = String.format(stringResource(MR.strings.badges_subscription_cancelled_footer), date)) { + Text(String.format(stringResource(MR.strings.badges_subscription_ends), date)) + } + } else { + SubscriptionSection(badgeState.badgeType, footer = stringResource(MR.strings.badges_subscription_ended_footer)) { + Text(stringResource(MR.strings.badges_subscription_ended)) + } + } + BadgeSubscriptionState.Ended -> + SubscriptionSection(badgeState.badgeType, footer = stringResource(MR.strings.badges_subscription_ended_footer)) { + Text(stringResource(MR.strings.badges_subscription_ended)) + } + BadgeSubscriptionState.Renewing, BadgeSubscriptionState.Unknown, null -> { + val price = BadgeStore.subscriptionPrice(badgeState.badgeType, subscription.planMonths) + if (ended) { + SubscriptionSection( + badgeState.badgeType, + footer = stringResource(if (badgeStoreLane) MR.strings.badges_subscription_renewing_footer else MR.strings.badges_subscription_renewing_footer_store) + ) { + Text(stringResource(MR.strings.badges_subscription_renewing)) + } + } else if (price != null) { + SubscriptionSection( + badgeState.badgeType, + footer = stringResource(if (badgeStoreLane) MR.strings.badges_subscription_renews_footer else MR.strings.badges_subscription_renews_footer_store) + ) { + Text(String.format(stringResource(MR.strings.badges_subscription_renews_price), date, price)) + } + } else { + SubscriptionSection( + badgeState.badgeType, + footer = stringResource(if (badgeStoreLane) MR.strings.badges_subscription_renews_footer else MR.strings.badges_subscription_renews_footer_store) + ) { + Text(String.format(stringResource(MR.strings.badges_subscription_renews), date)) + } + } + } + } + } else { + SectionView(stringResource(MR.strings.badges_ends)) { + Text(badgeState.paidThroughText, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 12.dp)) + } + SectionTextFooter(stringResource(MR.strings.badges_prepaid_footer)) + SectionSpacer() } - SectionTextFooter(stringResource(MR.strings.badges_prepaid_footer)) - SectionSpacer() SectionView { SettingsActionItem( painterResource(MR.images.ic_info), @@ -107,6 +167,23 @@ fun BadgesYourBadgeView(badgeState: BadgeState, modalManager: ModalManager) { } } +@Composable +private fun SubscriptionSection(badgeType: BadgeType, footer: String, status: @Composable RowScope.() -> Unit) { + SectionView(stringResource(MR.strings.badges_subscription)) { + SectionItemView(content = status) + if (badgeStoreLane) { + SettingsActionItem( + painterResource(MR.images.ic_open_in_new), + stringResource(MR.strings.badges_manage_subscription), + { BadgeStore.manageSubscriptions(badgeType) }, + textColor = MaterialTheme.colors.primary + ) + } + } + SectionTextFooter(footer) + SectionSpacer() +} + @Composable fun BadgeSummary(badgeState: BadgeState) { Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 70565fedb4..b27c7f5304 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -17,10 +17,10 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.previewDate import chat.simplex.common.views.helpers.ProfileImage import chat.simplex.common.views.usersettings.ProfilePreview import chat.simplex.res.* -import kotlinx.datetime.Clock // Spec: spec/services/calls.md#IncomingCallAlertView @Composable @@ -117,7 +117,7 @@ fun PreviewIncomingCallAlertLayout() { callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)), sharedKey = null, callUUID = "", - callTs = Clock.System.now() + callTs = previewDate ), chatModel = ChatModel, rejectCall = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 9b872c59c4..9f1d0d341c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -3858,26 +3858,26 @@ fun PreviewChatLayout() { SimpleXTheme { val chatItems = listOf( ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" + 1, CIDirection.DirectSnd(), previewDate, "hello" ), ChatItem.getSampleData( - 2, CIDirection.DirectRcv(), Clock.System.now(), "hello" + 2, CIDirection.DirectRcv(), previewDate, "hello" ), - ChatItem.getDeletedContentSampleData(3), + ChatItem.getDeletedContentSampleData(3, ts = previewDate), ChatItem.getSampleData( - 4, CIDirection.DirectSnd(), Clock.System.now(), "hello" + 4, CIDirection.DirectSnd(), previewDate, "hello" ), ChatItem.getSampleData( - 5, CIDirection.DirectSnd(), Clock.System.now(), "hello" + 5, CIDirection.DirectSnd(), previewDate, "hello" ), ChatItem.getSampleData( - 6, CIDirection.DirectRcv(), Clock.System.now(), "hello" + 6, CIDirection.DirectRcv(), previewDate, "hello" ) ) val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( - chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), + chatsCtx = remember { ChatModel.ChatsContext(secondaryContextFilter = null).also { it.chatItems.replaceAll(chatItems) } }, remoteHostId = remember { mutableStateOf(null) }, chat = remember { mutableStateOf(Chat.sampleData) }, unreadCount = unreadCount, @@ -3889,7 +3889,7 @@ fun PreviewChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - selectedChatItems = remember { mutableStateOf(setOf()) }, + selectedChatItems = remember { mutableStateOf(null) }, back = {}, info = {}, showReports = {}, @@ -3941,28 +3941,28 @@ fun PreviewGroupChatLayout() { SimpleXTheme { val chatItems = listOf( ChatItem.getSampleData( - 1, CIDirection.GroupSnd(), Clock.System.now(), "hello" + 1, CIDirection.GroupSnd(), previewDate, "hello" ), ChatItem.getSampleData( - 2, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + 2, CIDirection.GroupRcv(GroupMember.sampleData), previewDate, "hello" ), - ChatItem.getDeletedContentSampleData(3), + ChatItem.getDeletedContentSampleData(3, CIDirection.GroupRcv(GroupMember.sampleData), previewDate), ChatItem.getSampleData( - 4, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + 4, CIDirection.GroupRcv(GroupMember.sampleData), previewDate, "hello" ), ChatItem.getSampleData( - 5, CIDirection.GroupSnd(), Clock.System.now(), "hello" + 5, CIDirection.GroupSnd(), previewDate, "hello" ), ChatItem.getSampleData( - 6, CIDirection.GroupRcv(GroupMember.sampleData), Clock.System.now(), "hello" + 6, CIDirection.GroupRcv(GroupMember.sampleData), previewDate, "hello" ) ) val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) } val searchValue = remember { mutableStateOf("") } ChatLayout( - chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), + chatsCtx = remember { ChatModel.ChatsContext(secondaryContextFilter = null).also { it.chatItems.replaceAll(chatItems) } }, remoteHostId = remember { mutableStateOf(null) }, - chat = remember { mutableStateOf(Chat.sampleData) }, + chat = remember { mutableStateOf(Chat.sampleData.copy(chatInfo = ChatInfo.Group.sampleData)) }, unreadCount = unreadCount, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeView = { _ -> }, @@ -3972,7 +3972,7 @@ fun PreviewGroupChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - selectedChatItems = remember { mutableStateOf(setOf()) }, + selectedChatItems = remember { mutableStateOf(null) }, back = {}, info = {}, showReports = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 7f92ca0651..06cb751784 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -24,7 +24,6 @@ import chat.simplex.common.platform.getLoadedFilePath import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource -import kotlinx.datetime.Clock @Composable fun ContextItemView( @@ -161,7 +160,7 @@ fun ContextItemView( fun PreviewContextItemView() { SimpleXTheme { ContextItemView( - contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), + contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), previewDate, "hello")), contextIcon = painterResource(MR.images.ic_edit_filled), chatInfo = Chat.sampleData.chatInfo ) {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index ea841acc86..3ad2906759 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -44,7 +44,7 @@ fun ModalData.ChannelMembersView( val s = searchText.value.text.trim().lowercase() val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state } - val title = if (groupInfo.isOwner) { + val title = if (groupInfo.canModerate) { generalGetString(MR.strings.channel_members_title_subscribers) } else { generalGetString(MR.strings.channel_members_section_owners) @@ -57,7 +57,7 @@ fun ModalData.ChannelMembersView( item { AppBarTitle(title) } - if (groupInfo.isOwner) { + if (groupInfo.canModerate) { val showSearch = members.size > 8 item { SectionView(title = subscriberCountStr(subscriberCount)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 973b684d38..8cbbeb18c7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -651,7 +651,7 @@ fun ModalData.GroupChatInfoLayout( ModalManager.closeAllModalsEverywhere() } } - if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + if (groupInfo.canModerate || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { anyTopSectionRowShow = true ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) } @@ -1295,7 +1295,7 @@ private fun ShareViaChatButton(onClick: () -> Unit) { @Composable private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { - val title = if (groupInfo.isOwner) { + val title = if (groupInfo.canModerate) { stringResource(MR.strings.channel_members_title_subscribers) } else { stringResource(MR.strings.channel_members_section_owners) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ac1c6915b4..dc06d15ea6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -594,6 +594,8 @@ fun GroupMemberInfoLayout( when (member.memberRole) { GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + GroupMemberRole.Moderator -> stringResource(MR.strings.member_info_section_title_moderator) + GroupMemberRole.Member -> stringResource(MR.strings.member_info_section_title_contributor) else -> stringResource(MR.strings.member_info_section_title_subscriber) } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index bd3af10c37..c52bb5b0f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -178,10 +178,14 @@ private fun GroupPreferencesLayout( } } } - @Composable fun SignMessagesPreference() { + @Composable fun SignMessagesPreference(onEnable: ((() -> Unit) -> Unit)? = null) { val enableSignMessages = remember(preferences) { mutableStateOf(preferences.signMessages.enable) } FeatureSection(GroupFeature.SignMessages, enableSignMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> applyPrefs(preferences.copy(signMessages = GroupPreference(enable = enable))) + if (enable == GroupFeatureEnabled.ON && currentPreferences.signMessages.enable != GroupFeatureEnabled.ON) onEnable?.invoke { + enableSignMessages.value = GroupFeatureEnabled.OFF + applyPrefs(preferences.copy(signMessages = GroupPreference(enable = GroupFeatureEnabled.OFF))) + } } } ColumnWithScrollBar { @@ -216,9 +220,16 @@ private fun GroupPreferencesLayout( SectionDividerSpaced() SupportPreference(disabled = true) } else { - // hidden until message signing is user-facing (recipient-only stage) -// SignMessagesPreference() -// SectionDividerSpaced() + SignMessagesPreference(onEnable = { revert -> + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.sign_messages_question), + text = generalGetString(MR.strings.sign_messages_alert_desc), + confirmText = generalGetString(MR.strings.sign_messages_enable), + onDismiss = revert, + onDismissRequest = revert, + ) + }) + SectionDividerSpaced() TimedMessagesPreference() SectionDividerSpaced() FullDeletePreference() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 38eaa6f170..4c80490eba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -168,7 +168,7 @@ fun CIGroupInvitationView( fun PendingCIGroupInvitationViewPreview() { SimpleXTheme { CIGroupInvitationView( - ci = ChatItem.getGroupInvitationSample(), + ci = previewDated(ChatItem.getGroupInvitationSample()), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, joinGroup = { _, _ -> }, @@ -186,7 +186,7 @@ fun PendingCIGroupInvitationViewPreview() { fun CIGroupInvitationViewAcceptedPreview() { SimpleXTheme { CIGroupInvitationView( - ci = ChatItem.getGroupInvitationSample(), + ci = previewDated(ChatItem.getGroupInvitationSample()), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, joinGroup = { _, _ -> }, @@ -201,7 +201,7 @@ fun CIGroupInvitationViewAcceptedPreview() { fun CIGroupInvitationViewLongNamePreview() { SimpleXTheme { CIGroupInvitationView( - ci = ChatItem.getGroupInvitationSample(), + ci = previewDated(ChatItem.getGroupInvitationSample()), groupInvitation = CIGroupInvitation.getSample( groupProfile = GroupProfile("group_with_a_really_really_really_long_name", "Group With A Really Really Really Long Name", shortDescr = null), status = CIGroupInvitationStatus.Accepted diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index ba22ae5966..3230408c41 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -27,7 +27,6 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.appPreferences import chat.simplex.common.ui.theme.isInDarkTheme import chat.simplex.res.* -import kotlinx.datetime.Clock @Composable fun CIMetaView( @@ -282,7 +281,7 @@ private fun StatusIconText(icon: Painter, color: Color) { fun PreviewCIMetaView() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" + 1, CIDirection.DirectSnd(), previewDate, "hello" ), null, showViaProxy = false, @@ -295,7 +294,7 @@ fun PreviewCIMetaView() { fun PreviewCIMetaViewUnread() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + 1, CIDirection.DirectSnd(), previewDate, "hello", status = CIStatus.RcvNew() ), null, @@ -309,7 +308,7 @@ fun PreviewCIMetaViewUnread() { fun PreviewCIMetaViewSendFailed() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + 1, CIDirection.DirectSnd(), previewDate, "hello", status = CIStatus.CISSndError(SndError.Other("CMD SYNTAX")) ), null, @@ -323,7 +322,7 @@ fun PreviewCIMetaViewSendFailed() { fun PreviewCIMetaViewSendNoAuth() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth() + 1, CIDirection.DirectSnd(), previewDate, "hello", status = CIStatus.SndErrorAuth() ), null, showViaProxy = false, @@ -336,7 +335,7 @@ fun PreviewCIMetaViewSendNoAuth() { fun PreviewCIMetaViewSendSent() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete) + 1, CIDirection.DirectSnd(), previewDate, "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete) ), null, showViaProxy = false, @@ -349,7 +348,7 @@ fun PreviewCIMetaViewSendSent() { fun PreviewCIMetaViewEdited() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + 1, CIDirection.DirectSnd(), previewDate, "hello", itemEdited = true ), null, @@ -363,7 +362,7 @@ fun PreviewCIMetaViewEdited() { fun PreviewCIMetaViewEditedUnread() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", + 1, CIDirection.DirectRcv(), previewDate, "hello", itemEdited = true, status= CIStatus.RcvNew() ), @@ -378,7 +377,7 @@ fun PreviewCIMetaViewEditedUnread() { fun PreviewCIMetaViewEditedSent() { CIMetaView( chatItem = ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + 1, CIDirection.DirectSnd(), previewDate, "hello", itemEdited = true, status= CIStatus.SndSent(SndCIStatusProgress.Complete) ), @@ -392,7 +391,7 @@ fun PreviewCIMetaViewEditedSent() { @Composable fun PreviewCIMetaViewDeletedContent() { CIMetaView( - chatItem = ChatItem.getDeletedContentSampleData(), + chatItem = ChatItem.getDeletedContentSampleData(ts = previewDate), null, showViaProxy = false, showTimestamp = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 4305a35502..77e296041b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -37,7 +37,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource -import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlin.math.* // TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code) @@ -1496,10 +1496,15 @@ fun moderateMessagesAlertDialog(itemIds: List, questionText: String, delet expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) +val previewDate = Instant.fromEpochSeconds(1_736_936_100) + +fun previewDated(chatItem: ChatItem): ChatItem = + chatItem.copy(meta = chatItem.meta.copy(itemTs = previewDate, createdAt = previewDate, updatedAt = previewDate)) + @Preview @Composable fun PreviewChatItemView( - chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), Clock.System.now(), "hello") + chatItem: ChatItem = ChatItem.getSampleData(1, CIDirection.DirectSnd(), previewDate, "hello") ) { ChatItemView( chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), @@ -1556,7 +1561,7 @@ fun PreviewChatItemViewDeletedContent() { chatsCtx = ChatModel.ChatsContext(secondaryContextFilter = null), rhId = null, Chat.sampleData, - ChatItem.getDeletedContentSampleData(), + ChatItem.getDeletedContentSampleData(ts = previewDate), useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt index 70bf88a51c..9f7d4bee08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt @@ -51,7 +51,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, fun PreviewDeletedItemView() { SimpleXTheme { DeletedItemView( - ChatItem.getDeletedContentSampleData(), + ChatItem.getDeletedContentSampleData(ts = previewDate), null, showViaProxy = false, showTimestamp = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 2869ce4168..ef74f8698b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -98,7 +98,7 @@ fun IntegrityErrorItemViewPreview() { SimpleXTheme { IntegrityErrorItemView( MsgErrorType.MsgBadHash(), - ChatItem.getDeletedContentSampleData(), + ChatItem.getDeletedContentSampleData(ts = previewDate), showTimestamp = true, null, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 67ac3b293f..dd17c372b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -17,7 +17,6 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.* import dev.icerock.moko.resources.compose.stringResource -import kotlinx.datetime.Clock @Composable fun MarkedDeletedItemView(chatsCtx: ChatModel.ChatsContext, ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { @@ -120,7 +119,7 @@ fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String = fun PreviewMarkedDeletedItemView() { SimpleXTheme { DeletedItemView( - ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())), + ChatItem.getSampleData(ts = previewDate, itemDeleted = CIDeleted.Deleted(previewDate)), null, showViaProxy = false, showTimestamp = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 32e0befa3f..5b7e2ee995 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -25,12 +25,12 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.group.* import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chat.item.previewDate import chat.simplex.common.views.contacts.onRequestAccepted import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.res.* import kotlinx.coroutines.* -import kotlinx.datetime.Clock // Spec: spec/client/chat-list.md#ChatListNavLinkView @Composable @@ -1052,7 +1052,7 @@ fun PreviewChatListNavLinkDirect() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), - Clock.System.now(), + previewDate, "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." ) ), @@ -1097,7 +1097,7 @@ fun PreviewChatListNavLinkGroup() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), - Clock.System.now(), + previewDate, "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." ) ), @@ -1134,7 +1134,7 @@ fun PreviewChatListNavLinkContactRequest() { SimpleXTheme { ChatListNavLinkLayout( chatLinkPreview = { - ContactRequestView(ChatInfo.ContactRequest.sampleData) + ContactRequestView(ChatInfo.ContactRequest(UserContactRequest.sampleData.run { UserContactRequest(contactRequestId, cReqChatVRange, localDisplayName, profile, createdAt = previewDate, updatedAt = previewDate) })) }, click = {}, dropdownMenuItems = null, 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 3f3b39117e..e0bfae72d1 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 @@ -64,6 +64,28 @@ sealed class ActiveFilter { data object Unread: ActiveFilter() } +private fun renewalPrice(): String? { + val badgeState = BadgeModel.badgeState.value ?: return null + val subscription = badgeState.subscription ?: return null + return BadgeStore.subscriptionPrice(badgeState.badgeType, subscription.planMonths) +} + +private fun renewalFailure(): String { + val issueError = BadgeModel.badgeState.value?.issueError ?: return generalGetString(MR.strings.badges_renewal_failed_unknown) + return issueError.reason.text +} + +@Composable +private fun BadgeAlertBanner(title: String, subtitle: String, warning: Boolean = false) { + SupportSimpleXBanner( + title = title, + subtitle = subtitle, + warning = warning, + onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, + onDismiss = { showBadgeAlertDismissAlert(title) } + ) +} + private fun showBadgeAlertDismissAlert(title: String) { AlertManager.shared.showAlertDialogButtonsColumn( title = title, @@ -1073,31 +1095,33 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ToggleChatListCard() } } - // one slot: a badge the user paid for ending outranks the pitch to get one - val alert = BadgeModel.alert.value - if (supportEnded() && alert != null) { + // one slot: an alert about a badge the user paid for outranks the pitch to get one + val alert = bannerBadgeAlert() + if (alert != null) { item { - SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeExpired } + SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeAlert } Box(Modifier.zIndex(1f).padding(16.dp)) { - SupportSimpleXBanner( - title = stringResource(MR.strings.badges_support_ended), - subtitle = String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText), - onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, - onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_support_ended)) } - ) - } - } - } else if (badgeIssueFailed()) { - item { - SideEffect { chatModel.chatListBanner = ChatListBanner.BadgeIssueFailed } - Box(Modifier.zIndex(1f).padding(16.dp)) { - SupportSimpleXBanner( - title = stringResource(MR.strings.badges_renewal_failed), - subtitle = stringResource(MR.strings.badges_tap_for_details), - warning = true, - onTap = { ModalManager.start.showCustomModal(id = ModalViewId.BADGES) { close -> BadgesView(ModalManager.start, close) } }, - onDismiss = { showBadgeAlertDismissAlert(generalGetString(MR.strings.badges_renewal_failed)) } - ) + when (alert.kind) { + is BadgeAlertKind.SupportEnded -> + BadgeAlertBanner(stringResource(MR.strings.badges_support_ended), String.format(stringResource(MR.strings.badges_support_ended_on), alert.dateText)) + is BadgeAlertKind.IssueFailed -> + BadgeAlertBanner(stringResource(MR.strings.badges_renewal_failed), renewalFailure(), warning = true) + is BadgeAlertKind.SubscriptionEnded -> + BadgeAlertBanner(stringResource(MR.strings.badges_subscription_ended_title), String.format(stringResource(MR.strings.badges_subscription_ended_on), alert.dateText)) + is BadgeAlertKind.PaymentIssue -> + BadgeAlertBanner(stringResource(MR.strings.badges_payment_problem_title), stringResource(MR.strings.badges_payment_problem_subtitle), warning = true) + is BadgeAlertKind.SubscriptionCancelled -> + BadgeAlertBanner(stringResource(MR.strings.badges_subscription_cancelled_title), String.format(stringResource(MR.strings.badges_stays_until), alert.dateText)) + is BadgeAlertKind.RenewalApproaching -> { + val price = renewalPrice() + if (price != null) { + BadgeAlertBanner(stringResource(MR.strings.badges_renews_soon), String.format(stringResource(MR.strings.badges_renews_on_for), alert.dateText, price)) + } else { + BadgeAlertBanner(stringResource(MR.strings.badges_renews_soon), String.format(stringResource(MR.strings.badges_renews_on), alert.dateText)) + } + } + is BadgeAlertKind.PrepaidEnding, is BadgeAlertKind.Unknown -> {} + } } } } else if (chatModel.bannerSlotFree(ChatListBanner.BadgePitch) && !supporterBannerShown.value && noShownBadge() && chatModel.chats.value.size > 3) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 89d2dfb811..9392889552 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -644,6 +644,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(Chat.sampleData.copy(chatItems = Chat.sampleData.chatItems.map(::previewDated)), true, null, null, "", disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 82cffbb384..d28f4b3ae6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -208,7 +208,7 @@ private fun setContactAlias(rhId: Long?, contactConnection: PendingContactConnec name = "Dark Mode" )*/ @Composable -private fun PreviewContactConnectionInfoView() { +internal fun PreviewContactConnectionInfoView() { SimpleXTheme { ContactConnectionInfoLayout( chatModel = ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index bdad9d19c4..84dd8f0431 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -872,7 +872,7 @@ fun ActionButton( @Preview @Composable -private fun PreviewNewChatSheet() { +internal fun PreviewNewChatSheet() { SimpleXTheme { ModalData().NewChatSheet(rh = null, close = {}) } 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 cf708c72d8..89be582fa1 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 @@ -46,20 +46,25 @@ 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 +// the onboarding cards replace the whole chat list, and the badge alert banner lives in the +// list - a 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) && !supportEnded() && !badgeIssueFailed() + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) && bannerBadgeAlert() == null } -fun supportEnded(): Boolean = - BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) - -fun badgeIssueFailed(): Boolean = - BadgeModel.alert.value?.kind == BadgeAlertKind.IssueFailed && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) +// the current profile's badge alert, if it is a kind the chat list has a banner for +fun bannerBadgeAlert(): BadgeAlert? { + val alert = BadgeModel.alert.value + if (alert == null || !BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId)) return null + return when (alert.kind) { + is BadgeAlertKind.SupportEnded, is BadgeAlertKind.IssueFailed, is BadgeAlertKind.SubscriptionEnded, + is BadgeAlertKind.PaymentIssue, is BadgeAlertKind.SubscriptionCancelled, is BadgeAlertKind.RenewalApproaching -> alert + is BadgeAlertKind.PrepaidEnding, is BadgeAlertKind.Unknown -> null + } +} // false until the badge state loads: if the pitch rendered before that, it would lock the slot, and a supporter's badge // arriving a moment later would hide it, leaving the slot empty for the session diff --git a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml index a5984af8ac..c128c35ac4 100644 --- a/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/moko-resources/base/strings.xml @@ -713,6 +713,7 @@ File will be received when your contact completes uploading it. File will be received when your contact is online, please wait or check later! File saved + File already exists. Replace it? File not found Error saving file Formatted view @@ -803,10 +804,13 @@ Do not require signing messages. Message signing is required. Message signing is not required. + Sign messages? + Messages sent before will show "signature missing" to subscribers who join later. + Enable Show signature Show encryption Signature missing - The channel required this message to be signed, but the signature is missing. + The message was sent before the channel started signing messages, or it is fake. Live message! Send a live message - it will update for the recipient(s) as you type it Send @@ -3108,6 +3112,8 @@ Relay Owner + Moderator + Contributor Subscriber Channel Relay link @@ -3259,6 +3265,8 @@ This app version cannot buy badges. This profile already has a badge. Redeem the code on another profile, or once this badge ends. This profile already has a badge. + A store purchase is being added to this profile. Redeem the code once it is added, or on another profile. + A store purchase is being added to this profile. This code was not recognized. This code has already been used. This code has expired. @@ -3270,6 +3278,7 @@ The badge service reported an internal error. The store did not confirm this purchase. This purchase was already credited to a badge on another device or profile. + This purchase was moved to another device or profile recently. Test purchases are not credited. Badges can only be bought with a real purchase. The payment has not been confirmed yet. The store could not be reached. @@ -3307,7 +3316,7 @@ From purchase key To purchase key Badge renewal failed - Tap for details + Your badge could not be renewed. The badge service refused the renewal: %1$s The badge service refused the purchase: %1$s The badge service did not respond. @@ -3318,6 +3327,42 @@ Last attempt Contact SimpleX team Next check + Payment is charged to your Google Play account when you confirm the purchase. The subscription renews automatically at %1$s per month until you cancel it. You can manage and cancel it in Google Play subscriptions. + Payment is charged to your Google Play account when you confirm the purchase. The subscription renews automatically at %1$s per year until you cancel it. You can manage and cancel it in Google Play subscriptions. + Privacy policy + Restore purchases + Adds a badge you paid for. A subscription bought with this Google Play account moves to this profile, and the profile or device that had it loses it. + Nothing to restore + No Google Play purchase waiting to be added was found. + Restore failed + This Google Play account already has this purchase, on another profile or device. To add it to this profile, use Restore purchases. + The store could not be reached. + This badge is not on sale. + Subscription + Manage subscription + Payment problem + Google Play could not charge your payment method and is retrying. Update it to keep your badge. Until the payment goes through, you cannot buy or redeem another badge. + The store could not charge your payment method and is retrying. Update it to keep your badge. Until the payment goes through, you cannot buy or redeem another badge. + Ends %1$s + Your subscription is cancelled. The badge stays until %1$s. + Ended + Your subscription has ended. The badge will be removed from your profile. + Renewing + Your badge is renewed once Google Play charges your subscription. Until then you cannot buy or redeem another badge. + Your badge is renewed once the store charges your subscription. Until then you cannot buy or redeem another badge. + Renews %1$s · %2$s + Renews %1$s + Your subscription renews automatically. You can cancel it in Google Play. + Your subscription renews automatically. You can cancel it in the store. + Your subscription ended + Your badge subscription ended on %1$s. + Badge payment problem + The store could not charge your payment method. + Subscription cancelled + Your badge stays until %1$s. + Your badge renews soon + Renews on %1$s for %2$s. + Renews on %1$s. Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt index b779954efc..9720a78435 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt @@ -75,9 +75,10 @@ actual class FileChooserLauncher actual constructor() { ) res = simplexWindowState.openDialog.awaitResult(params) } else { - res = simplexWindowState.saveDialog.awaitResult(DialogParams(filename = input)) + val filename = saveDialogFileName(input) + res = simplexWindowState.saveDialog.awaitResult(DialogParams(filename = filename)) if (res != null && res.isDirectory) { - res = File(res, input) + res = File(res, filename) } } onResult(res?.toURI()) @@ -115,5 +116,7 @@ private fun fileFilterDescription(input: String): String = when(input) { else -> "" } +internal fun saveDialogFileName(name: String): String = name.trimStart('.').ifEmpty { name } + actual fun URI.inputStream(): InputStream? = toFile().inputStream() actual fun URI.outputStream(): OutputStream = toFile().outputStream() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt index 062f26cfd7..24958500a3 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Resources.desktop.kt @@ -26,9 +26,13 @@ actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle) actual fun StringResource.localized(): String = desc().toString() -private val detector: OsThemeDetector = OsThemeDetector.getDetector() +internal val systemThemeDetector: OsThemeDetector? = try { + OsThemeDetector.getDetector() +} catch (_: LinkageError) { + null +} actual fun isInNightMode() = try { - detector.isDark + systemThemeDetector?.isDark ?: false } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt index 8006b4febb..cb72aa660e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt @@ -4,10 +4,11 @@ import androidx.compose.runtime.Composable import chat.simplex.common.platform.Log import chat.simplex.common.platform.TAG import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.platform.systemThemeDetector import com.jthemedetecor.OsThemeDetector -private val detector: OsThemeDetector = OsThemeDetector.getDetector() - .apply { +private val detector: OsThemeDetector? = systemThemeDetector + ?.apply { registerListener(::reactOnDarkThemeChanges) if (desktopPlatform.isLinux()) Runtime.getRuntime().addShutdownHook(Thread(::stopGsettingsMonitor)) } @@ -23,7 +24,7 @@ private fun stopGsettingsMonitor() = @Composable actual fun isSystemInDarkTheme(): Boolean = try { - detector.isDark + detector?.isDark ?: false } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index 4f26f1d5fe..7b5d0ae371 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -17,6 +17,7 @@ import java.awt.FileDialog import java.awt.event.ActionListener import java.io.File import javax.swing.JFileChooser +import javax.swing.JOptionPane import javax.swing.filechooser.FileFilter import javax.swing.filechooser.FileNameExtensionFilter import javax.swing.plaf.basic.BasicFileChooserUI @@ -75,7 +76,21 @@ fun FrameWindowScope.FileDialogChooserMultiple( val scope = rememberCoroutineScope() DisposableEffect(Unit) { val job = scope.launch(Dispatchers.Main) { - val fileChooser = JFileChooser() + val fileChooser = object: JFileChooser() { + override fun approveSelection() { + if (!isLoad && selectedFile?.isFile == true) { + val answer = JOptionPane.showConfirmDialog( + this, + generalGetString(MR.strings.file_already_exists_replace_question), + dialogTitle, + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE + ) + if (answer != JOptionPane.YES_OPTION) return + } + super.approveSelection() + } + } fileChooser.dialogTitle = title fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad fileChooser.isAcceptAllFileFilterUsed = fileFilter == null diff --git a/apps/multiplatform/common/src/desktopMain/resources/simplelogger.properties b/apps/multiplatform/common/src/desktopMain/resources/simplelogger.properties new file mode 100644 index 0000000000..ea9eb5a00f --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.log.com.jthemedetecor.MacOSThemeDetector=off diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt index 392edf225c..a8e2c7a999 100644 --- a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.use +import org.jetbrains.skia.Image import kotlin.math.roundToInt data class Placed(val x: Int, val y: Int, val width: Int, val height: Int) @@ -25,10 +27,9 @@ fun Modifier.fixed(width: Int, height: Int, baseline: Int?): Modifier = layout { layout(width, height, if (baseline == null) emptyMap() else mapOf(FirstBaseline to baseline, LastBaseline to baseline)) { placeable.place(0, 0) } } -fun renderScene(direction: LayoutDirection = LayoutDirection.Ltr, content: @Composable () -> Unit) { - val scene = ImageComposeScene(width = 400, height = 400, density = Density(1f), layoutDirection = direction, content = content) - scene.render() - scene.close() -} +fun renderScene(direction: LayoutDirection = LayoutDirection.Ltr, width: Int = 400, height: Int = 400, frames: Int = 1, content: @Composable () -> Unit): Image = + ImageComposeScene(width = width, height = height, density = Density(1f), layoutDirection = direction, content = content).use { scene -> + (0 until frames).map { scene.render(it * 1_000_000_000L) }.last() + } fun Placed.relativeTo(origin: Placed) = Placed(x - origin.x, y - origin.y, width, height) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt new file mode 100644 index 0000000000..21b3d2e341 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SaveDialogFileNameTest.kt @@ -0,0 +1,20 @@ +package chat.simplex.app + +import chat.simplex.common.platform.saveDialogFileName +import kotlin.test.Test +import kotlin.test.assertEquals + +class SaveDialogFileNameTest { + @Test + fun testLeadingDotsRemoved() { + assertEquals("bashrc", saveDialogFileName(".bashrc")) + assertEquals("hidden.txt", saveDialogFileName("..hidden.txt")) + } + + @Test + fun testOtherNamesUnchanged() { + assertEquals("report.pdf", saveDialogFileName("report.pdf")) + assertEquals("archive.tar.gz", saveDialogFileName("archive.tar.gz")) + assertEquals("...", saveDialogFileName("...")) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt new file mode 100644 index 0000000000..1ca2f7bec8 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt @@ -0,0 +1,234 @@ +package chat.simplex.app + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import chat.simplex.common.AppPreview +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel +import chat.simplex.common.simplexWindowState +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.PreviewTerminalLayout +import chat.simplex.common.views.call.PreviewIncomingCallAlertLayout +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.group.* +import chat.simplex.common.views.chat.item.* +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.database.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.* +import chat.simplex.common.views.onboarding.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Image +import org.jetbrains.skia.Surface +import java.io.File +import java.util.Locale +import java.util.TimeZone +import kotlin.math.roundToInt +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val SCENE_WIDTH = 402 +private const val SCENE_MAX_HEIGHT = 874 + +private val chatItemFontScales = listOf(1f, 1.4f, 1.5f) + +private class SnapshotScene(val name: String, val fontScales: List = listOf(1f), val content: @Composable () -> Unit) + +@Serializable +private class Snapshot(val scene: String, val theme: String, val fontScale: Float, val file: String, val width: Int, val height: Int) + +private val indexJson = Json { prettyPrint = true } + +private val previewScenes = listOf( + SnapshotScene("TerminalView.PreviewTerminalLayout") { PreviewTerminalLayout() }, + SnapshotScene("IncomingCallAlertView.PreviewIncomingCallAlertLayout") { PreviewIncomingCallAlertLayout() }, + SnapshotScene("ChatInfoView.PreviewChatInfoLayout") { chat.simplex.common.views.chat.PreviewChatInfoLayout() }, + SnapshotScene("ChatView.PreviewChatLayout") { PreviewChatLayout() }, + SnapshotScene("ChatView.PreviewGroupChatLayout") { PreviewGroupChatLayout() }, + SnapshotScene("ComposeFileView.PreviewComposeFileView") { PreviewComposeFileView() }, + SnapshotScene("ComposeVoiceView.PreviewComposeAudioView") { PreviewComposeAudioView() }, + SnapshotScene("ContextItemView.PreviewContextItemView") { PreviewContextItemView() }, + SnapshotScene("SendMsgView.PreviewSendMsgView") { PreviewSendMsgView() }, + SnapshotScene("SendMsgView.PreviewSendMsgViewEditing") { PreviewSendMsgViewEditing() }, + SnapshotScene("SendMsgView.PreviewSendMsgViewInProgress") { PreviewSendMsgViewInProgress() }, + SnapshotScene("AddGroupMembersView.PreviewAddGroupMembersLayout") { PreviewAddGroupMembersLayout() }, + SnapshotScene("GroupChatInfoView.PreviewGroupChatInfoLayout") { PreviewGroupChatInfoLayout() }, + SnapshotScene("GroupMemberInfoView.PreviewGroupMemberInfoLayout") { PreviewGroupMemberInfoLayout() }, + SnapshotScene("CIEventView.CIEventViewPreview", chatItemFontScales) { CIEventViewPreview() }, + SnapshotScene("CIGroupInvitationView.PendingCIGroupInvitationViewPreview", chatItemFontScales) { PendingCIGroupInvitationViewPreview() }, + SnapshotScene("CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview", chatItemFontScales) { CIGroupInvitationViewAcceptedPreview() }, + SnapshotScene("CIGroupInvitationView.CIGroupInvitationViewLongNamePreview", chatItemFontScales) { CIGroupInvitationViewLongNamePreview() }, + SnapshotScene("CIMetaView.PreviewCIMetaView", chatItemFontScales) { SimpleXTheme { PreviewCIMetaView() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewUnread", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewUnread() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewSendFailed", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewSendFailed() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewSendNoAuth", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewSendNoAuth() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewSendSent", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewSendSent() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewEdited", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewEdited() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewEditedUnread", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewEditedUnread() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewEditedSent", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewEditedSent() } }, + SnapshotScene("CIMetaView.PreviewCIMetaViewDeletedContent", chatItemFontScales) { SimpleXTheme { PreviewCIMetaViewDeletedContent() } }, + SnapshotScene("ChatItemView.PreviewChatItemView", chatItemFontScales) { SimpleXTheme { PreviewChatItemView() } }, + SnapshotScene("ChatItemView.PreviewChatItemViewDeletedContent", chatItemFontScales) { PreviewChatItemViewDeletedContent() }, + SnapshotScene("DeletedItemView.PreviewDeletedItemView", chatItemFontScales) { PreviewDeletedItemView() }, + SnapshotScene("IntegrityErrorItemView.IntegrityErrorItemViewPreview", chatItemFontScales) { IntegrityErrorItemViewPreview() }, + SnapshotScene("MarkedDeletedItemView.PreviewMarkedDeletedItemView", chatItemFontScales) { PreviewMarkedDeletedItemView() }, + SnapshotScene("ChatHelpView.PreviewChatHelpLayout") { PreviewChatHelpLayout() }, + SnapshotScene("ChatListNavLinkView.PreviewChatListNavLinkDirect") { PreviewChatListNavLinkDirect() }, + SnapshotScene("ChatListNavLinkView.PreviewChatListNavLinkGroup") { PreviewChatListNavLinkGroup() }, + SnapshotScene("ChatListNavLinkView.PreviewChatListNavLinkContactRequest") { PreviewChatListNavLinkContactRequest() }, + SnapshotScene("ChatPreviewView.PreviewChatPreviewView") { PreviewChatPreviewView() }, + SnapshotScene("DatabaseEncryptionView.PreviewDatabaseEncryptionLayout") { PreviewDatabaseEncryptionLayout() }, + SnapshotScene("DatabaseErrorView.PreviewChatInfoLayout") { chat.simplex.common.views.database.PreviewChatInfoLayout() }, + SnapshotScene("DatabaseView.PreviewDatabaseLayout") { PreviewDatabaseLayout() }, + SnapshotScene("ChatInfoImage.PreviewChatInfoImage") { PreviewChatInfoImage() }, + SnapshotScene("LinkPreviews.PreviewChatItemLinkView") { PreviewChatItemLinkView() }, + SnapshotScene("LinkPreviews.PreviewComposeLinkView") { PreviewComposeLinkView() }, + SnapshotScene("LinkPreviews.PreviewComposeLinkViewLoading") { PreviewComposeLinkViewLoading() }, + SnapshotScene("SimpleButton.PreviewShareButton") { PreviewShareButton() }, + SnapshotScene("ContactConnectionInfoView.PreviewContactConnectionInfoView") { PreviewContactConnectionInfoView() }, + SnapshotScene("NewChatSheet.PreviewNewChatSheet") { PreviewNewChatSheet() }, + SnapshotScene("QRCode.PreviewQRCode") { PreviewQRCode() }, + SnapshotScene("HowItWorks.PreviewHowItWorks") { PreviewHowItWorks() }, + SnapshotScene("SimpleXInfo.PreviewSimpleXInfo") { PreviewSimpleXInfo() }, + SnapshotScene("WhatsNewView.PreviewWhatsNewView") { PreviewWhatsNewView() }, + SnapshotScene("HelpView.PreviewHelpView") { PreviewHelpView() }, + SnapshotScene("MarkdownHelpView.PreviewMarkdownHelpView") { PreviewMarkdownHelpView() }, + SnapshotScene("SettingsView.PreviewSettingsLayout") { PreviewSettingsLayout() }, + SnapshotScene("UserAddressView.PreviewUserAddressLayoutNoAddress") { PreviewUserAddressLayoutNoAddress() }, + SnapshotScene("UserAddressView.PreviewUserAddressLayoutAddressCreated") { PreviewUserAddressLayoutAddressCreated() }, + SnapshotScene("AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout") { PreviewAdvancedNetworkSettingsLayout() }, + SnapshotScene("NetworkAndServers.PreviewNetworkAndServersLayout") { PreviewNetworkAndServersLayout() }, + SnapshotScene("DesktopApp.AppPreview") { AppPreview() }, +) + +private val previewsCallingCore = listOf( + "AddChannelView.PreviewAddChannelView", + "AddGroupView.PreviewAddGroupLayout", + "GroupProfileView.PreviewGroupProfileLayout", + "UserProfileView.PreviewUserProfileLayoutEditOff", + "UserProfileView.PreviewUserProfileLayoutEditOn", +) + +private val previewFunction = Regex("""@Preview\s+(?:@Composable\s+)?(?:\w+\s+)*fun\s+(\w+)""") + +class SnapshotTest { + @Test + fun everyPreviewIsRendered() { + assertEquals(previewsInSources(), (previewScenes.map { it.name } + previewsCallingCore).sorted()) + } + + @Test + fun renderPreviews() { + val dir = File(System.getenv("SNAPSHOT_DIR") ?: "build/snapshots").apply { mkdirs() } + val locale = Locale.getDefault() + val timeZone = TimeZone.getDefault() + val theme = appPrefs.currentTheme.get()!! + val themeOverrides = appPrefs.themeOverrides.get() + val fontScale = appPrefs.fontScale.get() + val index = mutableListOf() + val failures = mutableListOf() + try { + Locale.setDefault(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + simplexWindowState.windowState = WindowState(size = DpSize(SCENE_WIDTH.dp, SCENE_MAX_HEIGHT.dp)) + appPrefs.themeOverrides.set(listOf(ThemeOverrides(base = DefaultTheme.LIGHT), ThemeOverrides(base = DefaultTheme.DARK))) + for (base in listOf(DefaultTheme.LIGHT, DefaultTheme.DARK)) { + ThemeManager.applyTheme(base.themeName) + val themeName = base.name.lowercase() + for (scene in previewScenes) { + for (scale in scene.fontScales) { + appPrefs.fontScale.set(scale) + ChatModel.terminalItems.value = listOf() + val file = "${scene.name}-$themeName-font-${(scale * 100).roundToInt()}.png" + try { + val image = renderSnapshot(scene.content) + File(dir, file).writeBytes(image.encodeToData(EncodedImageFormat.PNG)!!.bytes) + index += Snapshot(scene.name, themeName, scale, file, image.width, image.height) + } catch (e: Throwable) { + failures += "$file: ${e.stackTraceToString()}" + } + } + } + } + File(dir, "index-desktop.json").writeText(indexJson.encodeToString(index)) + } finally { + Locale.setDefault(locale) + TimeZone.setDefault(timeZone) + appPrefs.themeOverrides.set(themeOverrides) + appPrefs.fontScale.set(fontScale) + ThemeManager.applyTheme(theme) + } + assertEquals(emptyList(), failures) + } +} + +private fun renderSnapshot(content: @Composable () -> Unit): Image { + var height = 0 + val frame = renderScene(width = SCENE_WIDTH, height = SCENE_MAX_HEIGHT, frames = 4) { + Box(Modifier.width(SCENE_WIDTH.dp).background(CurrentColors.collectAsState().value.colors.background).onSizeChanged { height = it.height }) { + CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(), content = content) + } + } + return Surface.makeRasterN32Premul(SCENE_WIDTH, height).run { + canvas.drawImage(frame, 0f, 0f) + makeImageSnapshot() + } +} + +private fun previewsInSources(): List = + listOf("src/commonMain/kotlin", "src/desktopMain/kotlin") + .flatMap { dir -> File(dir).walk().filter { it.extension == "kt" } } + .flatMap { file -> previewFunction.findAll(withoutComments(file.readText())).map { "${file.nameWithoutExtension}.${it.groupValues[1]}" } } + .sorted() + +private fun withoutComments(source: String): String { + val code = StringBuilder() + var i = 0 + while (i < source.length) { + val end = tokenEnd(source, i) + if (source.startsWith("//", i) || source.startsWith("/*", i)) code.append(' ') else code.append(source, i, end) + i = end + } + return code.toString() +} + +private fun tokenEnd(source: String, start: Int): Int = when { + source.startsWith("//", start) -> source.indexOf('\n', start).takeIf { it >= 0 } ?: source.length + source.startsWith("/*", start) -> { + var depth = 0 + var i = start + do { + when { + source.startsWith("/*", i) -> { depth++; i += 2 } + source.startsWith("*/", i) -> { depth--; i += 2 } + else -> i++ + } + } while (depth > 0) + i + } + source.startsWith("\"\"\"", start) -> { + var i = source.indexOf("\"\"\"", start + 3) + while (source.startsWith("\"\"\"\"", i)) i++ + i + 3 + } + source[start] == '"' || source[start] == '\'' -> { + var i = start + 1 + while (source[i] != source[start]) i += if (source[i] == '\\') 2 else 1 + i + 1 + } + else -> start + 1 +} diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ASSETS_LICENSE.md b/apps/multiplatform/common/src/desktopTest/snapshots/ASSETS_LICENSE.md new file mode 100644 index 0000000000..3ce290fc5c --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/snapshots/ASSETS_LICENSE.md @@ -0,0 +1,18 @@ +# Application Graphic Assets License + +Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. + +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, visual designs, and screenshots of the applications, such as the snapshot images of the tests - are proprietary and are not licensed under the AGPLv3 that covers the application source code. + +## Permitted use + +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. The snapshot images of the tests are not needed to build the application and are not part of it. +- Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. + +## Not permitted + +All other use, including modification, redistribution, or incorporation into other works, is not permitted without prior written permission from SimpleX Chat Ltd. + +## Contact + +To request permission, contact chat@simplex.chat. diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-dark-font-100.png new file mode 100644 index 0000000000..63f68ad79e Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-light-font-100.png new file mode 100644 index 0000000000..bc2af1285a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/AddGroupMembersView.PreviewAddGroupMembersLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-dark-font-100.png new file mode 100644 index 0000000000..640a28de88 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-light-font-100.png new file mode 100644 index 0000000000..170b8e11a8 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/AdvancedNetworkSettings.PreviewAdvancedNetworkSettingsLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-100.png new file mode 100644 index 0000000000..47e5443dbb Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-140.png new file mode 100644 index 0000000000..e524dc0138 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-150.png new file mode 100644 index 0000000000..116a9e37b3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-100.png new file mode 100644 index 0000000000..8955d757c5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-140.png new file mode 100644 index 0000000000..776eaea74b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-150.png new file mode 100644 index 0000000000..9bd328cb79 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIEventView.CIEventViewPreview-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-100.png new file mode 100644 index 0000000000..1be3ec308e Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-140.png new file mode 100644 index 0000000000..f34a175632 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-150.png new file mode 100644 index 0000000000..37132993b6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-100.png new file mode 100644 index 0000000000..04e0c9e637 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-140.png new file mode 100644 index 0000000000..799ca6a3b5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-150.png new file mode 100644 index 0000000000..7eeb8d7e74 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewAcceptedPreview-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-100.png new file mode 100644 index 0000000000..b3234637f9 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-140.png new file mode 100644 index 0000000000..bbf30745b3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-150.png new file mode 100644 index 0000000000..124c803792 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-100.png new file mode 100644 index 0000000000..4a270e5a3a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-140.png new file mode 100644 index 0000000000..8f7cf16c45 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-150.png new file mode 100644 index 0000000000..d2092ba2d9 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.CIGroupInvitationViewLongNamePreview-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-100.png new file mode 100644 index 0000000000..51466e9596 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-140.png new file mode 100644 index 0000000000..98d880ad23 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-150.png new file mode 100644 index 0000000000..c1e63e0488 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-100.png new file mode 100644 index 0000000000..ffb01cead6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-140.png new file mode 100644 index 0000000000..c5183d756d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-150.png new file mode 100644 index 0000000000..3401b232b3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIGroupInvitationView.PendingCIGroupInvitationViewPreview-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-100.png new file mode 100644 index 0000000000..4484109847 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-140.png new file mode 100644 index 0000000000..f507c80b68 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-150.png new file mode 100644 index 0000000000..0b9830910d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-100.png new file mode 100644 index 0000000000..11bbbe483d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-140.png new file mode 100644 index 0000000000..029f92f6c5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-150.png new file mode 100644 index 0000000000..523cff52ca Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaView-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-100.png new file mode 100644 index 0000000000..1495d0da75 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-140.png new file mode 100644 index 0000000000..ee5e9db032 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-150.png new file mode 100644 index 0000000000..4c7c806677 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-100.png new file mode 100644 index 0000000000..526a8a9e6c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-140.png new file mode 100644 index 0000000000..e0c97d1ae6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-150.png new file mode 100644 index 0000000000..91d77ed3b6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewDeletedContent-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-100.png new file mode 100644 index 0000000000..9dbb5caefc Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-140.png new file mode 100644 index 0000000000..909385957f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-150.png new file mode 100644 index 0000000000..4c551cd4f2 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-100.png new file mode 100644 index 0000000000..d9ba35f656 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-140.png new file mode 100644 index 0000000000..3bc4526122 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-150.png new file mode 100644 index 0000000000..fa3d3b6e74 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEdited-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-100.png new file mode 100644 index 0000000000..6ee18fc9db Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-140.png new file mode 100644 index 0000000000..5064017326 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-150.png new file mode 100644 index 0000000000..4bccb91477 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-100.png new file mode 100644 index 0000000000..6d320af33b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-140.png new file mode 100644 index 0000000000..47ef475803 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-150.png new file mode 100644 index 0000000000..17564e8df2 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedSent-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-100.png new file mode 100644 index 0000000000..a6eacab400 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-140.png new file mode 100644 index 0000000000..90a229f28d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-150.png new file mode 100644 index 0000000000..f5e406d5de Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-100.png new file mode 100644 index 0000000000..934c1eca18 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-140.png new file mode 100644 index 0000000000..8b716db006 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-150.png new file mode 100644 index 0000000000..ddea7efaf3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewEditedUnread-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-100.png new file mode 100644 index 0000000000..c3c53577aa Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-140.png new file mode 100644 index 0000000000..e4b0d36231 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-150.png new file mode 100644 index 0000000000..13fbff4a84 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-100.png new file mode 100644 index 0000000000..227c7cbdf0 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-140.png new file mode 100644 index 0000000000..f48ba86540 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-150.png new file mode 100644 index 0000000000..de95c1b9e5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendFailed-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-100.png new file mode 100644 index 0000000000..c3c53577aa Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-140.png new file mode 100644 index 0000000000..e4b0d36231 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-150.png new file mode 100644 index 0000000000..13fbff4a84 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-100.png new file mode 100644 index 0000000000..227c7cbdf0 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-140.png new file mode 100644 index 0000000000..f48ba86540 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-150.png new file mode 100644 index 0000000000..de95c1b9e5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendNoAuth-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-100.png new file mode 100644 index 0000000000..82cd629c83 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-140.png new file mode 100644 index 0000000000..02fa32e1bc Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-150.png new file mode 100644 index 0000000000..ec96888709 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-100.png new file mode 100644 index 0000000000..600a9c6794 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-140.png new file mode 100644 index 0000000000..b42109551d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-150.png new file mode 100644 index 0000000000..7646d57442 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewSendSent-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-100.png new file mode 100644 index 0000000000..c92e144b70 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-140.png new file mode 100644 index 0000000000..316920a5ea Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-150.png new file mode 100644 index 0000000000..53e1ae9991 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-100.png new file mode 100644 index 0000000000..c1d155c599 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-140.png new file mode 100644 index 0000000000..d7a3b0f3be Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-150.png new file mode 100644 index 0000000000..5fbea26cb1 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/CIMetaView.PreviewCIMetaViewUnread-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-dark-font-100.png new file mode 100644 index 0000000000..bf5510d988 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-light-font-100.png new file mode 100644 index 0000000000..c117430d42 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatHelpView.PreviewChatHelpLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-dark-font-100.png new file mode 100644 index 0000000000..1f097f7167 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-light-font-100.png new file mode 100644 index 0000000000..a90e32115d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoImage.PreviewChatInfoImage-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-dark-font-100.png new file mode 100644 index 0000000000..0da216e382 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-light-font-100.png new file mode 100644 index 0000000000..75c5061c37 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatInfoView.PreviewChatInfoLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-100.png new file mode 100644 index 0000000000..53a05f4a0c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-140.png new file mode 100644 index 0000000000..a5b4ee678c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-150.png new file mode 100644 index 0000000000..3cc4b8c1f4 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-100.png new file mode 100644 index 0000000000..9459087b49 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-140.png new file mode 100644 index 0000000000..22a9c4e992 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-150.png new file mode 100644 index 0000000000..9cf37f3f62 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemView-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-100.png new file mode 100644 index 0000000000..79cfd77e4f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-140.png new file mode 100644 index 0000000000..7a079b8f73 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-150.png new file mode 100644 index 0000000000..63a755dfa7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-100.png new file mode 100644 index 0000000000..11de7f8734 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-140.png new file mode 100644 index 0000000000..82c2330980 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-150.png new file mode 100644 index 0000000000..ee106318eb Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatItemView.PreviewChatItemViewDeletedContent-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-dark-font-100.png new file mode 100644 index 0000000000..07062558ce Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-light-font-100.png new file mode 100644 index 0000000000..be5df892f7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkContactRequest-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-dark-font-100.png new file mode 100644 index 0000000000..a1f2b4f2bc Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-light-font-100.png new file mode 100644 index 0000000000..9ab7ebe1a5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkDirect-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-dark-font-100.png new file mode 100644 index 0000000000..2bdf6ef76f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-light-font-100.png new file mode 100644 index 0000000000..4ddaab6563 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatListNavLinkView.PreviewChatListNavLinkGroup-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-dark-font-100.png new file mode 100644 index 0000000000..c9b36454b6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-light-font-100.png new file mode 100644 index 0000000000..35d569463a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatPreviewView.PreviewChatPreviewView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-dark-font-100.png new file mode 100644 index 0000000000..7e24a89508 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-light-font-100.png new file mode 100644 index 0000000000..f8fbf224d7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewChatLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-dark-font-100.png new file mode 100644 index 0000000000..3689d34fbb Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-light-font-100.png new file mode 100644 index 0000000000..051e759b50 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ChatView.PreviewGroupChatLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-dark-font-100.png new file mode 100644 index 0000000000..0b2063c260 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-light-font-100.png new file mode 100644 index 0000000000..73eaf47e04 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeFileView.PreviewComposeFileView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-dark-font-100.png new file mode 100644 index 0000000000..0b2063c260 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-light-font-100.png new file mode 100644 index 0000000000..73eaf47e04 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ComposeVoiceView.PreviewComposeAudioView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-dark-font-100.png new file mode 100644 index 0000000000..babfa14943 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-light-font-100.png new file mode 100644 index 0000000000..82e016ee9e Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ContactConnectionInfoView.PreviewContactConnectionInfoView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-dark-font-100.png new file mode 100644 index 0000000000..b76f86fa07 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-light-font-100.png new file mode 100644 index 0000000000..61bf4c371c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/ContextItemView.PreviewContextItemView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-dark-font-100.png new file mode 100644 index 0000000000..96c96bcd3f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-light-font-100.png new file mode 100644 index 0000000000..74c7eb32c0 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseEncryptionView.PreviewDatabaseEncryptionLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-dark-font-100.png new file mode 100644 index 0000000000..f12a08e63a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-light-font-100.png new file mode 100644 index 0000000000..96f6380677 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseErrorView.PreviewChatInfoLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-dark-font-100.png new file mode 100644 index 0000000000..9ef4fc95c3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-light-font-100.png new file mode 100644 index 0000000000..6d7bb7e0df Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DatabaseView.PreviewDatabaseLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-100.png new file mode 100644 index 0000000000..ab7ffc742f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-140.png new file mode 100644 index 0000000000..d9a2ead1eb Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-150.png new file mode 100644 index 0000000000..b514a1380f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-100.png new file mode 100644 index 0000000000..cbd0c75e60 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-140.png new file mode 100644 index 0000000000..a3598dbaf3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-150.png new file mode 100644 index 0000000000..6f63a95d52 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DeletedItemView.PreviewDeletedItemView-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-dark-font-100.png new file mode 100644 index 0000000000..392292c002 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-light-font-100.png new file mode 100644 index 0000000000..63964d2a46 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/DesktopApp.AppPreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-dark-font-100.png new file mode 100644 index 0000000000..c7f5afb5ec Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-light-font-100.png new file mode 100644 index 0000000000..afb2b58ac4 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/GroupChatInfoView.PreviewGroupChatInfoLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-dark-font-100.png new file mode 100644 index 0000000000..78902dfb4c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-light-font-100.png new file mode 100644 index 0000000000..872468f62b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/GroupMemberInfoView.PreviewGroupMemberInfoLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-dark-font-100.png new file mode 100644 index 0000000000..831cc32655 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-light-font-100.png new file mode 100644 index 0000000000..4e84f3db3f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/HelpView.PreviewHelpView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-dark-font-100.png new file mode 100644 index 0000000000..87379671e5 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-light-font-100.png new file mode 100644 index 0000000000..c62e9d8044 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/HowItWorks.PreviewHowItWorks-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-dark-font-100.png new file mode 100644 index 0000000000..54469a2b14 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-light-font-100.png new file mode 100644 index 0000000000..9af7829db6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IncomingCallAlertView.PreviewIncomingCallAlertLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-100.png new file mode 100644 index 0000000000..af5e257340 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-140.png new file mode 100644 index 0000000000..da7b05c47a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-150.png new file mode 100644 index 0000000000..a45b350c88 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-100.png new file mode 100644 index 0000000000..8bc6ccb41b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-140.png new file mode 100644 index 0000000000..686fc00357 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-150.png new file mode 100644 index 0000000000..24157f9f8c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/IntegrityErrorItemView.IntegrityErrorItemViewPreview-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-dark-font-100.png new file mode 100644 index 0000000000..efd4d08187 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-light-font-100.png new file mode 100644 index 0000000000..0258057446 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewChatItemLinkView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-dark-font-100.png new file mode 100644 index 0000000000..0f6e8d168b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-light-font-100.png new file mode 100644 index 0000000000..8d3c6c731b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-dark-font-100.png new file mode 100644 index 0000000000..f4850353df Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-light-font-100.png new file mode 100644 index 0000000000..f2445d2f18 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/LinkPreviews.PreviewComposeLinkViewLoading-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-dark-font-100.png new file mode 100644 index 0000000000..f06b8f467c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-light-font-100.png new file mode 100644 index 0000000000..608fc1a34f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkdownHelpView.PreviewMarkdownHelpView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-100.png new file mode 100644 index 0000000000..3fe86f4322 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-140.png new file mode 100644 index 0000000000..8a263a1522 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-150.png new file mode 100644 index 0000000000..3144b7946c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-dark-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-100.png new file mode 100644 index 0000000000..e23d0858ac Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-140.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-140.png new file mode 100644 index 0000000000..23981ea6bc Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-140.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-150.png b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-150.png new file mode 100644 index 0000000000..89c2571d89 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/MarkedDeletedItemView.PreviewMarkedDeletedItemView-light-font-150.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-dark-font-100.png new file mode 100644 index 0000000000..231c998726 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-light-font-100.png new file mode 100644 index 0000000000..a9a7b496d7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/NetworkAndServers.PreviewNetworkAndServersLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-dark-font-100.png new file mode 100644 index 0000000000..e3b50a005a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-light-font-100.png new file mode 100644 index 0000000000..d2ff2bfa31 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/NewChatSheet.PreviewNewChatSheet-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-dark-font-100.png new file mode 100644 index 0000000000..e84f06e103 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-light-font-100.png new file mode 100644 index 0000000000..053da1d2ec Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/QRCode.PreviewQRCode-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-dark-font-100.png new file mode 100644 index 0000000000..5af9d2fa6b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-light-font-100.png new file mode 100644 index 0000000000..cfffc076e1 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgView-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-dark-font-100.png new file mode 100644 index 0000000000..548b420e6d Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-light-font-100.png new file mode 100644 index 0000000000..e06bd6c88f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewEditing-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-dark-font-100.png new file mode 100644 index 0000000000..5af9d2fa6b Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-light-font-100.png new file mode 100644 index 0000000000..cfffc076e1 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SendMsgView.PreviewSendMsgViewInProgress-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-dark-font-100.png new file mode 100644 index 0000000000..c6a0463485 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-light-font-100.png new file mode 100644 index 0000000000..84008700e7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SettingsView.PreviewSettingsLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-dark-font-100.png new file mode 100644 index 0000000000..ffbb3bd7c1 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-light-font-100.png new file mode 100644 index 0000000000..2cf7d21858 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleButton.PreviewShareButton-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-dark-font-100.png new file mode 100644 index 0000000000..91a973dcd8 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-light-font-100.png new file mode 100644 index 0000000000..346f09c00c Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/SimpleXInfo.PreviewSimpleXInfo-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-dark-font-100.png new file mode 100644 index 0000000000..5b13f91ab3 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-light-font-100.png new file mode 100644 index 0000000000..b3cc291d9f Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/TerminalView.PreviewTerminalLayout-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-dark-font-100.png new file mode 100644 index 0000000000..c38addfc85 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-light-font-100.png new file mode 100644 index 0000000000..6b993c3150 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutAddressCreated-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-dark-font-100.png new file mode 100644 index 0000000000..866b63bea7 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-light-font-100.png new file mode 100644 index 0000000000..fb363f3c4a Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/UserAddressView.PreviewUserAddressLayoutNoAddress-light-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-dark-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-dark-font-100.png new file mode 100644 index 0000000000..4b9103eab6 Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-dark-font-100.png differ diff --git a/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-light-font-100.png b/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-light-font-100.png new file mode 100644 index 0000000000..70a76858de Binary files /dev/null and b/apps/multiplatform/common/src/desktopTest/snapshots/WhatsNewView.PreviewWhatsNewView-light-font-100.png differ diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 7e8f841775..2caf213e0e 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -15,6 +15,7 @@ This document catalogs known gaps in the multiplatform codebase (Android and Des 7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) 8. [Desktop: App Link Registration](#gap-08-desktop-app-link-registration) 9. [Desktop: Emoji Picker Without Skin Tones and Localized Search](#gap-09-desktop-emoji-picker-without-skin-tones-and-localized-search) +10. [Badges: Limits on Moving a Badge to a New Device or Profile](#gap-10-badges-limits-on-moving-a-badge-to-a-new-device-or-profile) --- @@ -349,3 +350,30 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag 1. Keep the skin tone variants in the generated data, grouped by base emoji, and add a skin tone choice to the picker. 2. Add localized names (CLDR annotations) to the generated data for search. + +--- + +## GAP-10: Badges: Limits on Moving a Badge to a New Device or Profile + +**Severity:** Low +**Category:** Feature / Payments +**Platforms:** Android + +### Description + +Play gives a resubscription a new purchase token, so subscribing again on the same install is credited as a new purchase. On a new device or under another profile, Restore hands over the subscriptions in Play's list as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. The app starts no plan change, and when tested Play's own pages offered none for these products; a plan change made there, should one become possible, would get a new token, which the app's sweep finds but core does not record, so only Restore claims it, and not under a profile that shows a badge, so the profile showing the old badge claims it only once that badge ends; until then it stays unacknowledged, which Play refunds after three days. + +### Affected Locations + +- `common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt` -- `restorePurchases()` +- `../../apps/simplex-badge-service/src/BadgeService/Service.hs` -- the move limit + +### Impact + +A subscriber who moves the badge again before the previous key's credential expires waits until it does, and the first such Restore may give no reason. A badge bought with Stripe or crypto stays with the profile it was credited to. Should Play offer a plan change for these products, a subscriber who makes one keeps the old badge until its paid months end, or until the service reads the old subscription ended at a month's check, and can lose the new plan to Play's refund before it is credited. + +### Recommendation + +1. Restore by a receipt code for Stripe and crypto purchases, shared with iOS. +2. Announce a limited move on the first Restore. +3. Before a Play plan change becomes possible, credit its new token without waiting for Restore. diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md index 5a63ad92bc..64f7fefc87 100644 --- a/apps/multiplatform/spec/client/chat-list.md +++ b/apps/multiplatform/spec/client/chat-list.md @@ -339,7 +339,7 @@ Gradient card inviting the user to invest on Wefunder. Shown only when `crowdfun | Chat list | in `ChatList`'s `LazyColumn`, after `ToggleChatListCard` and before the chats | `Box(Modifier.zIndex(1f).padding(16.dp))` | | Onboarding | inside `ConnectOnboardingView` (`views/newchat/OnboardingCards.kt`), below the pager, so it shares the pages' width limit on desktop and their dimming while a start modal is open; opens the page in `ModalManager.center` on desktop, `ModalManager.start` on Android | `padding(start/end = DEFAULT_PADDING, bottom = 8.dp)`, in a `Column` where the pager takes `weight(1f)` | -The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the support-ended alert (`supportEnded()`), the renewal-failure alert (`badgeIssueFailed()`), the pitch, then the Wefunder banner. Each banner's `item` records itself in `ChatModel.chatListBanner` (`BadgeExpired`, `BadgeIssueFailed`, `BadgePitch`, `GetStake`) in a `SideEffect`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(banner)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge()` (`views/newchat/OnboardingCards.kt`), false until `BadgeModel.isCurrent` for the current user, so it cannot take the slot from a supporter whose badge loads a moment later. `ConnectOnboardingView` applies the same `bannerSlotFree` check and records `GetStake`. +The list has a single banner slot, filled by an `if`/`else if` chain in priority order: the badge alert banner, the pitch, then the Wefunder banner. The badge alert banner shows `bannerBadgeAlert()` (`views/newchat/OnboardingCards.kt`), the current profile's alert when its kind has a banner (support ended, renewal failed, subscription ended, payment problem, subscription cancelled, renewal soon; not prepaid ending or a kind from a newer core), and one `when` over the kind at the banner site picks its title, subtitle and warning colour; its X leads to the same remind-later or dismiss alert for every kind. The renewal-failed subtitle is `renewalFailure()`: the reason in `BadgeModel.badgeState`'s `issueError` (`BadgeIssueFailure.text`), or `badges_renewal_failed_unknown` ("Your badge could not be renewed.") when the state carries none, as after an alert that arrives without a new state; the payment-problem subtitle is `badges_payment_problem_subtitle` ("The store could not charge your payment method."), not `issueError`, which in billing retry is a failed renewal request rather than the payment's reason. Each banner's `item` records itself in `ChatModel.chatListBanner` (`BadgeAlert`, `BadgePitch`, `GetStake`) in a `SideEffect`, and the pitch and Wefunder conditions start with `chatModel.bannerSlotFree(banner)` — true only while nothing else was shown this app session — so dismissing a banner never puts another in its place until restart. The alerts have no such check: an alert takes the slot whenever present, and once shown it holds it. The pitch also requires `noShownBadge()` (`views/newchat/OnboardingCards.kt`), false until `BadgeModel.isCurrent` for the current user, so it cannot take the slot from a supporter whose badge loads a moment later. `ConnectOnboardingView` applies the same `bannerSlotFree` check and records `GetStake`. `crowdfundingAvailable()` launches an effect to load the store country, so both call sites read it in the composable body rather than inside the `LazyColumn` builder. @@ -352,7 +352,7 @@ The list has a single banner slot, filled by an `if`/`else if` chain in priority | `supporterBannerTapped` | tapping the supporter pitch | the pitch's dismiss X appears from then on | | `supporterBannerShown` | the pitch's dismiss X, through its "You can support SimpleX later in Settings." alert, and a successful code redemption | hides the pitch | -The two badge alert banners always offer the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. +The badge alert banner always offers the X; only the pitch waits to be tapped once, so a user who has not looked at it cannot dismiss it unseen. Both are in `AppPreferences.hintPreferences`, so "Reset all hints" restores the banner. The X is never offered below the onboarding cards, so the banner cannot be dismissed before the user has a chat. diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index 4900cfaba7..04da4e9b33 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -109,11 +109,11 @@ When onboarding is complete: ### URL Deep Link -On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L775), which re-queues it while no profile is active. +On Android, [`processIntent()`](../../android/src/main/java/chat/simplex/app/MainActivity.kt#L144) puts the URI of a VIEW intent into `chatModel.appOpenUrl`. Once onboarding is complete, a `LaunchedEffect` in `MainScreen` ([`App.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L241)) hands it to [`connectIfOpenedViaUri()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L797), which re-queues it while no profile is active. On desktop the same state is set from three places, for `simplexchat:` app links and `simplex:` connection links alike. [`isAcceptedLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L12) accepts either scheme within 8192 bytes in UTF-8, which leaves room for a one-time link with its post-quantum key (about 2 KB) and for a second such key. A link that starts the app arrives as the only argument of [`main()`](../../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L26) on Windows and Linux ([`linkFromArgs()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L15)). A link for an app that is already running starts a second process, which writes it into the single-instance signal file ([`signalRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L138)); the running instance renames the file before reading and deleting it ([`takeSignal()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L159)), retrying once if another program holds the file open, handles a signal already present when its watcher ([`watchShowSignals()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L247)) starts unless it predates the lock attempt by more than 2 s ([`deleteStaleSignalFiles()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L116)), and passes the link to [`openDesktopLink()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L19), which queues it for the active host and shows the window. If the running instance does not take the signal within 1 s, the second process asks whether to start anyway ([`startDespiteRunningInstance()`](../../common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt#L93)); on *Yes* it withdraws the signal first and exits instead if the running instance has recovered and taken it, so the link opens once. On macOS the link is an Apple Event handled by [`installOpenUriHandler()`](../../common/src/desktopMain/kotlin/chat/simplex/common/AppLinks.kt#L25). From there `connectIfOpenedViaUri()` handles the link as on Android: a connection link goes to `planAndConnect()`, which asks before connecting. Both schemes are declared by the macOS Info.plist, the deb, AppImage and Flatpak desktop entries, and at runtime by [`registerLinkSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L67) (HKCU on Windows, a hidden desktop entry for an AppImage). Only `simplexchat:` is checked: [`appLinkSchemeRegistered()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L65) decides the badge page's ending through [`badgePageUrl()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeStore.kt#L53): `app=true` when a link is expected to reach this installation, otherwise `app=desktop` with the Redeem code screen opened beside the browser. [`desktopInstallation()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L74) tells the installation kind apart. A packaged macOS app and a Flatpak count as registered without a check, since a Flatpak cannot query the host's default handler ([GAP](../../product/gaps.md#gap-08-desktop-app-link-registration)); Windows, where each scheme key is paired with a `SimpleX.` ProgId named in `Capabilities` and listed in `RegisteredApplications`, so the app appears in Windows default-app settings for each scheme that no other program holds, when the shell (`AssocQueryStringW`) reports this exe as the program that opens `simplexchat:` links, which accounts for any app picked in settings ([`registerWindowsSchemes()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L168)); an AppImage ([`registerAppImageScheme()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L299)) or deb when its own desktop entry is the default `simplexchat:` handler. Registration takes a scheme only when no handler is set or the current one is no longer installed: another installed program, such as a deb beside an AppImage or on Windows a per-user or machine-wide handler, keeps it, while this app still repairs its own ProgId unless another installed copy holds it, and the page then ends with `app=desktop`. An AppImage is not registered ([`onlyOwnerCanReplace()`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppLinkScheme.desktop.kt#L284)) when its file or any directory above it belongs to a user other than this user or root, or has permission bits that let others write it (POSIX ACLs are not inspected), or when its path contains a line break or `%`. An unpackaged run registers nothing. -App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L797) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L804) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown ([`showBadgeAddedAlert()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string. +App links use the app's own scheme, `simplexchat:`, for everything that is not a connection link. `connectIfOpenedViaUri()` branches on that scheme before the connection dispatch, so an app link never reaches `planAndConnect()`. [`isAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L819) tests the raw prefix, since text that does not parse as a URI is still an app link, and [`openAppLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L826) dispatches on its path. A badge link (`simplexchat:/badge/code/`) goes to [`openBadgeLink()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L282), which shows `BadgesRedeemLinkView` as a `ModalManager.start` modal with `ModalViewId.BADGE_LINK`. Once the code parses, every modal is closed first, so the screen opens over the chat list, as iOS dismisses every sheet before a link; a code that does not parse gets an alert and closes nothing. The screen names the active profile and asks before redeeming, since any web page can send the link and a profile holds one badge at a time. On *Add badge* it redeems the code into that profile and then shows `BadgesView`; *Cancel* sends nothing. If that profile already shows a badge (read from `BadgeModel` when the screen composes), which core would refuse, the screen does not offer to add it: it says nothing was added, says where a code for another badge is (on the page it was bought on, under *Show code*), and offers *View your badge*; it cannot tell a repeated link for the badge it shows from another badge's code, so it asserts neither. While the code is being redeemed, *Dismiss* or back closes the screen without cancelling anything; if the redemption then succeeds with the screen closed or covered, an alert says the badge was added and names the profile, unless the profile is hidden and no longer active, when nothing is shown ([`showBadgeAddedAlert()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L97), which the store lane shares). Its step is held by the modal rather than remembered by the composition, so rotation and being covered do not reset it. While the screen shows a redemption in flight ([`isBadgeLinkIssuing()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt#L279)), a further app link is ignored. Any other path is a link type from a later version, and gets an alert asking to check for app updates; text that does not parse is dropped. An app link can carry a secret, and nothing on this path logs the URI: `processIntent()` logs nothing, and `connectIfOpenedViaUri()` logs a fixed string. ### Overlay Layers (bottom of MainScreen) diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md index e278dbe2a9..c223032285 100644 --- a/apps/multiplatform/spec/services/theme.md +++ b/apps/multiplatform/spec/services/theme.md @@ -408,12 +408,12 @@ Delegates to the standard Compose function which reads `Configuration.uiMode`. **Desktop** ([`Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt)): ```kotlin -private val detector: OsThemeDetector = OsThemeDetector.getDetector() - .apply { registerListener(::reactOnDarkThemeChanges) } +private val detector: OsThemeDetector? = systemThemeDetector + ?.apply { registerListener(::reactOnDarkThemeChanges) } @Composable actual fun isSystemInDarkTheme(): Boolean = try { - detector.isDark + detector?.isDark ?: false } catch (e: Exception) { false // Fallback for macOS exceptions } @@ -421,6 +421,8 @@ actual fun isSystemInDarkTheme(): Boolean = try { Uses the [jSystemThemeDetector](https://github.com/Dansoftowner/jSystemThemeDetector) library (`OsThemeDetector`). The detector also registers a listener that calls `reactOnDarkThemeChanges()` proactively when the OS theme changes, ensuring the app responds even outside of composition. +`systemThemeDetector` is declared in `Resources.desktop.kt` as `OsThemeDetector.getDetector()` guarded by `catch (_: LinkageError)`. It is `null` when JNA's native library cannot be loaded; the app then starts with the light theme, `isInNightMode()` and `isSystemInDarkTheme()` return `false`, and no listener is registered. + ### reactOnDarkThemeChanges [`Theme.kt` line 763](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L763): diff --git a/apps/simplex-badge-service/README.md b/apps/simplex-badge-service/README.md index bb1cec2230..e2941f565b 100644 --- a/apps/simplex-badge-service/README.md +++ b/apps/simplex-badge-service/README.md @@ -51,7 +51,7 @@ simplex-badge-service --help `simplex-directory-service --run-cli`). This mode is the chat/RPC side and the `//` commands below: it starts no web listener and no poller, and serves no group commands, whatever `--service-config` says. It still updates a code's group message when the code is redeemed or - revoked. `[apple]`, `[google]` and `[dev] accept_unverified_store_receipts` apply to it. + revoked. `[apple]`, `[google]` and `[dev]` apply to it. - `--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: @@ -72,7 +72,8 @@ private_key = `index` is the index clients verify against (`badgePublicKeys` in `ChatConfig`), and `private_key` is the secret that signs. Both are required, and the key is checked at startup like the flags. -Rotating is a change to both and a restart, once clients trust the new index. +Rotating is a change to both and a restart, once clients trust the new index. The service logs the +index it signs with at startup. The command line wins over the file when both `--issuer-key-idx` and `--issuer-secret` are given. Note that a secret passed as a flag is visible to every user on the machine through `ps`, where one @@ -84,7 +85,7 @@ Other options: - `--service-config INI_FILE`: path to `badge_service.ini`. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under `--run-cli`, which parses and validates the whole file (`[listener] static_dir` included) but - uses only its `[issuer]`, `[apple]` and `[google]` sections and `[dev] accept_unverified_store_receipts`. An issuer key is + uses only its `[issuer]`, `[apple]`, `[google]` and `[dev]` sections. An issuer key is still required either way. - `--service-name NAME`: the bot's display name, without `*`s or spaces (default `SimpleX Badges`). - `--client-service`: use the client service certificate. @@ -221,6 +222,9 @@ account key, or either section beside [apple] bundle_id = chat.simplex.app root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer +api_key_id = 2X9R4HXF34 +api_issuer_id = 57246542-96fe-1a63-e053-0824d011072a +api_private_key_file = /etc/simplex-badge-service/AuthKey_2X9R4HXF34.p8 [google] package_name = chat.simplex.app @@ -237,9 +241,22 @@ service_account_file = /etc/simplex-badge-service/play-service-account.json under a new root, list it beside the old one rather than in its place: a purchase signed under the old root still verifies, and one answered `internal` meanwhile is credited when the app presents it again. -- `[google]` asks the Google Play Developer API about each purchase, and acknowledges it before - crediting it, as the service account whose JSON key `service_account_file` holds. The account - needs both Play Console permissions Google's +- With `api_key_id`, `api_issuer_id` and `api_private_key_file`, `[apple]` also verifies + subscriptions: it asks the App Store Server API for a subscription's status, with a token signed + by the in-app purchase key App Store Connect issued (the `.p8` file, its key id and the + account's issuer id), and checks what Apple signed in the answer against the same roots. Without + them, one-time purchases verify as before and every subscription is answered + `provider_not_configured`. The three are set together or not at all. `api_host` defaults to the + production host, `https://api.storekit.itunes.apple.com`, which answers nothing about a Sandbox + transaction; a test deployment with `[dev] credit_test_purchases = on` (below) sets + `https://api.storekit-sandbox.itunes.apple.com`. The service refuses to start with any other host, + or with the Sandbox host while `credit_test_purchases` is off, as the Sandbox never charges anyone; + the host's case and a trailing slash do not matter. A 401 or 403 + from Apple, the key refused, is answered `internal` and logged. Keep the key file readable by this + service alone. +- `[google]` asks the Google Play Developer API about each purchase and subscription, and + acknowledges it before crediting it, as the service account whose JSON key + `service_account_file` holds. The account needs both Play Console permissions Google's [getting started](https://developers.google.com/android-publisher/getting_started) lists for its billing APIs: "View financial data, orders, and cancellation survey responses" to read a purchase, and "Manage orders and subscriptions" to acknowledge it. Without the second, a Play @@ -247,12 +264,57 @@ service_account_file = /etc/simplex-badge-service/play-service-account.json error naming the acknowledgement, and the app presents it again once the permission is granted, which must be within three days, before Play refunds it. The key signs in for an access token, so keep the file readable by this service alone. - Play purchases are verified on a lane of their own, so a slow or unreachable Play delays only - other Play purchases, not code redemptions, renewals or App Store purchases. + A request that may ask a store waits on a lane of its own: Play purchases, App Store + subscription purchases and transfers of a subscription (Restore) on one, and issue requests and + tier raises for a subscription due its hourly store check on another. A slow or unreachable + store therefore delays only those, not code redemptions, App Store one-time purchases or other + renewals; and no purchase or transfer, genuine or made up, delays a renewal check. The purchase + path asks Play about at most 300 subscriptions a minute (a purchase it credits is read once more + to acknowledge it), so made-up tokens can spend at most a tenth of the quota renewal checks share. + +While a subscription's paid coverage runs, as an annual plan's does for a year, the service asks +its store before it issues each new month, at most once an hour. When the store reports the +subscription ended, or the charge the service granted revoked, the months not yet issued are debited +as a refund and the badge ends with the month already issued; a store that fails or does not answer +ends nothing. A Play refund that leaves the subscription active is not seen: the service does not +read Play's voided purchases. Which answers are terminal for a purchase and which leave it to be presented again is listed per store in [`docs/protocol/badges-rpc.md`](../../docs/protocol/badges-rpc.md#commands). +The root certificates, the App Store Server API key and the Play service account key are read only +at startup, so rotating any of them takes a restart. Until then the old key keeps being used, and +each request the store refuses it for is answered `internal` and logged as an error. + +Do not roll back the `20261012_badge_subscriptions` migration on a database that holds store +subscriptions. Its down migration drops their columns without refusing: every subscription becomes +a prepaid purchase that is never renewed, every Play purchase token is lost, and every ledger with a +charge, transfer or tier-change row can no longer be read, so its purchase answers `internal`. +Upgrading again restores none of it. Take a backup first if a rollback is unavoidable. + +#### Store setup + +The subscriptions must be sold under the ids the apps buy and the service credits, and set up as +follows: + +- [ ] App Store: one subscription group holding all four subscriptions, so a change of tier or plan + keeps the original transaction id, and with it the purchase. +- [ ] App Store levels: `SUBSCR_BADGE_LEGEND_MONTH_01` and `SUBSCR_BADGE_LEGEND_YEAR_01` at level 1, + `SUBSCR_BADGE_SUPPORTER_MONTH_01` and `SUBSCR_BADGE_SUPPORTER_YEAR_01` at level 2. Apple charges a + move to a higher level at once, and the apps send only Supporter to Legend as a raise. +- [ ] App Store: Billing Grace Period off, and Family Sharing off, which cannot be turned off once on: + a family-shared subscription is refused. +- [ ] Play: the product `subscr_badge_supporter_01` with the base plans + `subscr-badge-supporter-month-02` and `subscr-badge-supporter-year-01`, and the product + `subscr_badge_legend_01` with `subscr-badge-legend-month-01` and `subscr-badge-legend-year-01`. + A charge for any other base plan is not credited, and a charge for one of these under the other + product is answered `internal`. +- [ ] Play, on each of the four base plans: Resubscribe off, pausing off, the minimum grace period, + and account hold on. +- [ ] Both: no offers of any kind: no free trial, introductory or promotional price, or offer code. + The service credits no App Store charge made with an offer and no Play charge in a free trial or + introductory price. + ### Accepting store receipts unverified, for local testing ```ini @@ -267,9 +329,83 @@ token as given. It applies to both run modes, since `--run-cli` answers service the service logs a warning at every start while it is on. Off by default, and only `on`/`off` parse. The service refuses to start with it on beside an `[apple]` or `[google]` section. +It answers subscriptions too, each as renewing, charged from a minute before the check for the +length of its plan: + +- an App Store subscription: the plan its product names; +- a Play subscription: the base plan a test token `test..` names, when it is a + plan of the product (the apps' test products sign their tokens so), or else the product's + monthly base plan, so a licence tester's purchase is credited too; +- an App Store tier raise: a charge for the raised plan, which its renewals then charge; +- a renewal: a new charge for the plan it last charged. It keeps that plan in memory only, so after + a restart it answers a renewal of a subscription it credited before as ended, and the unissued + months of a plan still covered are refunded at its next month. + +It acknowledges nothing, so Play refunds a licence tester's subscription it credited once Play's +acknowledgement window lapses. + With it on, anyone who can reach the service address can mint badges by sending a made-up receipt. -That is harmless only while this deployment signs with an issuer key released apps do not carry, so -they refuse what it signs — never turn it on where the key is one those apps trust. +The service starts only with an issuer key released apps trust, so they accept every badge it +mints this way, and a subscription credited so renews at every check for as long as the process +runs. Never turn it on where anyone but you can reach the service. + +```ini +[dev] +accept_unverified_store_receipts = on +check_store_during_coverage = on +``` + +With `check_store_during_coverage` on as well, the service asks its store, the mock or a real one +with `credit_test_purchases` on (below), at every `issueBadge` for a subscription, rather than at +most once an hour and only when a month is due. A charge it reports during coverage is banked: a +charge row, the plan's months added to what is left and a later paid-through date, and those +months are issued once coverage reaches them; a deployment without the setting only records it. +So a renewal shows at once, though its credential waits for the paid month to end on the service's +clock, and after a restart the mock answers the subscription ended at once, so the months banked +are refunded. The mock charges anew at every +check, so every such request banks the plan's months again; space requests over a minute apart, as +the mock dates a charge a minute back and the app fails a ledger row dated before the one it +follows. The service logs each banked charge as +`store subscription charge banked: months`, which tells a banked month +from a repeated `badge issued`. Off by default, and the service refuses to start with it on unless +`accept_unverified_store_receipts` or `credit_test_purchases` is on: a real store has not started +its next charge while coverage runs, and asking it then would spend the quota every subscriber +shares. + +### Crediting test purchases, for testing against a real store + +```ini +[dev] +credit_test_purchases = on +``` + +A purchase the store itself calls a test, an App Store Sandbox purchase (which TestFlight builds +buy in) or a Play licence tester's, is refused `test_purchase` by default, which is terminal, so it +can never be credited. With this on, the service credits it as a real one, one-time purchases and +subscriptions alike. It applies beside a real `[apple]` or `[google]` section, which still verifies +every receipt, and changes nothing beside `accept_unverified_store_receipts`, whose mock reports +no test purchase. The service logs a warning at every start while it is on, and at every test +purchase and test subscription it takes as paid. Off by default, and only `on`/`off` parse. + +Beside `[apple]` the service starts with it on only when `[apple]` sets the server API key with +`api_host = https://api.storekit-sandbox.itunes.apple.com`: a Sandbox receipt verifies offline like +any other, so beside the production host, or without the key, Sandbox purchases would be credited as +real ones. Beside `[google]` it is allowed, as Play calls a purchase a test only for the licence +testers the Play Console lists. + +```ini +[dev] +credit_test_purchases = on +check_store_during_coverage = on +``` + +With both on, the real store is asked at every `issueBadge` for a subscription, as above, to watch +its renewals by hand: a Sandbox subscription renews every few minutes. + +The service starts only with an issuer key released apps trust, so they accept every badge it +credits this way. Nothing tells a production `[google]` from a test one, so never turn this on +beside the production Play app's `[google]`, or every licence tester the Play Console lists gets a +badge without paying. ## Issuing codes diff --git a/apps/simplex-badge-service/badge_service.ini.example b/apps/simplex-badge-service/badge_service.ini.example index 26abb5aa7e..5fbce6b7f3 100644 --- a/apps/simplex-badge-service/badge_service.ini.example +++ b/apps/simplex-badge-service/badge_service.ini.example @@ -45,9 +45,19 @@ idle_seconds = 60 ; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER. Today that ; is Apple Root CA - G3 as Apple publishes it: https://www.apple.com/certificateauthority/AppleRootCA-G3.cer ; When Apple adds a root, list it beside the old one rather than in its place. +; The api_ keys verify subscriptions through the App Store Server API, with the in-app purchase key +; App Store Connect issues: its .p8 file, its key id and the account's issuer id, set together. Without +; them every App Store subscription is answered provider_not_configured. api_host defaults to production, +; the only host accepted unless [dev] credit_test_purchases = on, which needs +; https://api.storekit-sandbox.itunes.apple.com instead; case and a trailing slash do not matter. +; Keep the key file readable by this service alone. ;[apple] ;bundle_id = chat.simplex.app ;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer +;api_key_id = 2X9R4HXF34 +;api_issuer_id = 57246542-96fe-1a63-e053-0824d011072a +;api_private_key_file = /etc/simplex-badge-service/AuthKey_2X9R4HXF34.p8 +;api_host = https://api.storekit.itunes.apple.com ; verifies Play purchases with the Google Play Developer API. Omit the whole section and the service answers ; every Play receipt provider_not_configured, which the app retries later. @@ -82,6 +92,29 @@ idle_seconds = 60 ; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without ; verifying it, so anyone who can reach this service can mint badges. The service refuses to start -; with this on beside an [apple] or [google] section. +; with this on beside an [apple] or [google] section. Its issuer key is always one released apps +; trust, so they accept every badge minted this way. +; A subscription is answered renewing and charged for its plan: the App Store product's, or the base +; plan a Play test token test.. names when it is a plan of that product, and else +; the product's monthly base plan, so a licence tester's purchase is credited too. An App Store tier +; raise is charged for the raised plan, which its renewals then charge. The plans are kept in memory, +; so a restart answers later renewals as ended, and refunds the unissued +; months of a plan still covered at its next month. Nothing is acknowledged, so Play refunds a +; licence tester's subscription once its acknowledgement window lapses. [dev] accept_unverified_store_receipts = off +; only beside accept_unverified_store_receipts or credit_test_purchases = on: every issue request for a +; subscription asks its store, while coverage runs too, and banks the months each check charges, to +; watch a renewal by hand. +check_store_during_coverage = off + +; local testing only, beside a real [apple] or [google]: credits a purchase the store itself calls a +; test - an App Store sandbox purchase, or a Play licence tester's - which no real buyer pays for. +; Without it such a purchase is refused as test_purchase, which is terminal, so it can never be credited. +; Beside [apple] the service starts with this on only when its api_ keys are set with +; api_host = https://api.storekit-sandbox.itunes.apple.com, since a sandbox receipt verifies like any +; other and would otherwise be credited as a real purchase. Beside [google] it is allowed: never turn it +; on beside the production app's [google], as the issuer key is one released apps trust and every +; licence tester the Play Console lists would get a badge without paying. Each test purchase or test +; subscription taken as paid is logged as a warning. +credit_test_purchases = off diff --git a/apps/simplex-badge-service/src/BadgeService/Catalog.hs b/apps/simplex-badge-service/src/BadgeService/Catalog.hs index 85b678d8d5..58fc246399 100644 --- a/apps/simplex-badge-service/src/BadgeService/Catalog.hs +++ b/apps/simplex-badge-service/src/BadgeService/Catalog.hs @@ -13,6 +13,7 @@ module BadgeService.Catalog defaultCatalog, StoreProduct (..), storeProduct, + playMonthlyPlan, ) where @@ -136,19 +137,32 @@ defaultCatalog seededAt = (prices, offers) createdAt = seededAt } -data StoreProduct = StoreProduct - { badgeType :: BadgeType, - months :: Int - } +-- | A subscription's badge and months come from each charge's plan, so the product names neither. +data StoreProduct + = StoreOneTime {badgeType :: BadgeType, months :: Int} + | StoreSubscription deriving (Eq, Show) -- | A receipt proves only the product paid for, so the badge comes from here, never from the client. --- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months. +-- Mirrors the SKUs of BadgeStore.swift and .kt. -- Not part of the seeded catalog above, which exists to pin prices this service sets: a store sets its own. storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct storeProduct provider productId = case (provider, productId) of - (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1 - (PPApple, "BADGE_LEGEND_01") -> Just $ StoreProduct BTLegend 1 - (PPGoogle, "badge_supporter_01") -> Just $ StoreProduct BTSupporter 1 - (PPGoogle, "badge_legend_01") -> Just $ StoreProduct BTLegend 1 + (PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreOneTime BTSupporter 1 + (PPApple, "BADGE_LEGEND_01") -> Just $ StoreOneTime BTLegend 1 + (PPGoogle, "badge_supporter_01") -> Just $ StoreOneTime BTSupporter 1 + (PPGoogle, "badge_legend_01") -> Just $ StoreOneTime BTLegend 1 + (PPApple, "SUBSCR_BADGE_SUPPORTER_MONTH_01") -> Just StoreSubscription + (PPApple, "SUBSCR_BADGE_SUPPORTER_YEAR_01") -> Just StoreSubscription + (PPApple, "SUBSCR_BADGE_LEGEND_MONTH_01") -> Just StoreSubscription + (PPApple, "SUBSCR_BADGE_LEGEND_YEAR_01") -> Just StoreSubscription + (PPGoogle, "subscr_badge_supporter_01") -> Just StoreSubscription + (PPGoogle, "subscr_badge_legend_01") -> Just StoreSubscription + _ -> Nothing + +-- | A Play subscription product's monthly base plan, which a purchase token alone does not name. +playMonthlyPlan :: Text -> Maybe Text +playMonthlyPlan = \case + "subscr_badge_supporter_01" -> Just "subscr-badge-supporter-month-02" + "subscr_badge_legend_01" -> Just "subscr-badge-legend-month-01" _ -> Nothing diff --git a/apps/simplex-badge-service/src/BadgeService/Config.hs b/apps/simplex-badge-service/src/BadgeService/Config.hs index 72e774fdac..8bd240ee56 100644 --- a/apps/simplex-badge-service/src/BadgeService/Config.hs +++ b/apps/simplex-badge-service/src/BadgeService/Config.hs @@ -11,6 +11,7 @@ module BadgeService.Config speedPolicyName, PollConfig (..), AppleStoreConfig (..), + AppleServerApi (..), PlayStoreConfig (..), BadgeIssuerKey (..), GroupConfig (..), @@ -24,7 +25,7 @@ where import qualified Control.Exception as E import BadgeService.Log (logWarn) -import Control.Monad (mfilter, when) +import Control.Monad (mfilter, unless, when) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) import qualified Data.ByteString.Char8 as B import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) @@ -97,7 +98,17 @@ data PollConfig = PollConfig {pWaitingSeconds :: Int, pIdleSeconds :: Int} data AppleStoreConfig = AppleStoreConfig { aBundleId :: Text, - aRootCertificates :: NonEmpty FilePath + aRootCertificates :: NonEmpty FilePath, + aServerApi :: Maybe AppleServerApi -- without it, App Store subscriptions are not verified + } + deriving (Eq, Show) + +-- | The App Store Server API key, which subscription checks sign their requests with. +data AppleServerApi = AppleServerApi + { aKeyId :: Text, + aIssuerId :: Text, + aPrivateKeyFile :: FilePath, + aApiHost :: Text } deriving (Eq, Show) @@ -135,7 +146,11 @@ data ServiceConfig = ServiceConfig issuer :: Maybe BadgeIssuerKey, group :: Maybe GroupConfig, -- Local testing only; anyone who can reach the service can mint badges with any well-formed receipt. - devAcceptUnverifiedStoreReceipts :: Bool + devAcceptUnverifiedStoreReceipts :: Bool, + -- Local testing only; every issue request for a subscription asks its store and banks the months it charges. + devCheckStoreDuringCoverage :: Bool, + -- Local testing only; credits a purchase the store itself calls a test, which no real buyer pays for. + devCreditTestPurchases :: Bool } deriving (Eq, Show) @@ -151,6 +166,13 @@ defaultSessionMinutes = 60 defaultPlayApiHost :: Text defaultPlayApiHost = "https://androidpublisher.googleapis.com" +-- | Production: it answers 404 for a Sandbox transaction, which a test deployment reaches through api_host. +defaultAppleApiHost :: Text +defaultAppleApiHost = "https://api.storekit.itunes.apple.com" + +appleSandboxHost :: Text +appleSandboxHost = "https://api.storekit-sandbox.itunes.apple.com" + defaultPlayTokenUrl :: Text defaultPlayTokenUrl = "https://oauth2.googleapis.com/token" @@ -169,6 +191,16 @@ requireHttps u maxTolerance :: Double maxTolerance = 10 +-- | A Sandbox receipt verifies like a real one, offline too, so test purchases are credited only beside the Sandbox's +-- server API; and the Sandbox charges no one, so without them only the production host can credit a subscription. +checkAppleHost :: Bool -> AppleStoreConfig -> Either String () +checkAppleHost creditTestPurchases AppleStoreConfig {aServerApi} = case (creditTestPurchases, aApiHost <$> aServerApi) of + (False, Nothing) -> Right () + (False, Just h) | h == defaultAppleApiHost -> Right () + (True, Just h) | h == appleSandboxHost -> Right () + (False, _) -> Left $ "apple.api_host must be " <> T.unpack defaultAppleApiHost <> " unless [dev] credit_test_purchases = on, as no other host credits a real subscription" + (True, _) -> Left $ "[dev] credit_test_purchases = on needs [apple]'s server API key with api_host = " <> T.unpack appleSandboxHost <> ", as a Sandbox receipt would otherwise be credited as a real purchase" + readServiceConfig :: FilePath -> IO (Either String ServiceConfig) readServiceConfig path = E.try (TIO.readFile path) >>= \case @@ -192,10 +224,10 @@ knownSettings = ("btcpay", ["host", "api_key", "store_id", "webhook_secret", "expiry_minutes", "speed_policy", "payment_tolerance"]), ("stripe", ["secret_key", "publishable_key", "webhook_secret", "session_minutes"]), ("poll", ["waiting_seconds", "idle_seconds"]), - ("apple", ["bundle_id", "root_certificates"]), + ("apple", ["bundle_id", "root_certificates", "api_key_id", "api_issuer_id", "api_private_key_file", "api_host"]), ("google", ["package_name", "service_account_file"]), ("group", ["display_name", "description"]), - ("dev", ["accept_unverified_store_receipts"]), + ("dev", ["accept_unverified_store_receipts", "check_store_during_coverage", "credit_test_purchases"]), ("issuer", ["index", "private_key"]) ] @@ -232,6 +264,11 @@ parseConfig ini = do devUnverifiedReceipts <- bool "dev" "accept_unverified_store_receipts" False when (devUnverifiedReceipts && (isJust apple || isJust google)) $ Left "[dev] accept_unverified_store_receipts must be off where [apple] or [google] verifies store receipts" + devCheckStoreDuringCoverage <- bool "dev" "check_store_during_coverage" False + devCreditTestPurchases <- bool "dev" "credit_test_purchases" False + mapM_ (checkAppleHost devCreditTestPurchases) apple + when (devCheckStoreDuringCoverage && not (devUnverifiedReceipts || devCreditTestPurchases)) $ + Left "[dev] check_store_during_coverage needs accept_unverified_store_receipts or credit_test_purchases = on, as only a dev deployment may ask a store while coverage runs" pure ServiceConfig { listener = ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor}, @@ -242,7 +279,9 @@ parseConfig ini = do playStore = google, issuer = iss, group = grp, - devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts + devAcceptUnverifiedStoreReceipts = devUnverifiedReceipts, + devCheckStoreDuringCoverage, + devCreditTestPurchases } where hasSection s = s `elem` sections ini @@ -339,11 +378,20 @@ parseConfig ini = do | otherwise = do aBundleId <- required "apple" "bundle_id" aRootCertificates <- rootCertificates - pure (Just AppleStoreConfig {aBundleId, aRootCertificates}) + aServerApi <- appleServerApi + pure (Just AppleStoreConfig {aBundleId, aRootCertificates, aServerApi}) rootCertificates = do v <- required "apple" "root_certificates" maybe (Left "apple.root_certificates must list at least one file") Right $ L.nonEmpty $ map T.unpack $ filter (not . T.null) $ map T.strip $ T.splitOn "," v + -- the key is three settings, and one or two of them would leave subscriptions silently unverified + appleServerApi = case (present "apple" "api_key_id", present "apple" "api_issuer_id", present "apple" "api_private_key_file") of + (Nothing, Nothing, Nothing) -> Right Nothing + (Just aKeyId, Just aIssuerId, Just keyFile) -> do + aApiHost <- T.toLower . T.dropWhileEnd (== '/') <$> optional "apple" "api_host" defaultAppleApiHost + unless ("https://" `T.isPrefixOf` aApiHost) $ Left "apple.api_host must be an absolute https URL" + pure (Just AppleServerApi {aKeyId, aIssuerId, aPrivateKeyFile = T.unpack keyFile, aApiHost}) + _ -> Left "apple.api_key_id, apple.api_issuer_id and apple.api_private_key_file are required together" googleSection | not (hasSection "google") = Right Nothing | otherwise = do diff --git a/apps/simplex-badge-service/src/BadgeService/Service.hs b/apps/simplex-badge-service/src/BadgeService/Service.hs index 72389c8cc7..d5d2b318a0 100644 --- a/apps/simplex-badge-service/src/BadgeService/Service.hs +++ b/apps/simplex-badge-service/src/BadgeService/Service.hs @@ -13,7 +13,21 @@ module BadgeService.Service badgeService, badgeServiceCLI, badgeServiceResponse, + RequestLane (..), + requestLane, + PurchaseChecks (..), + ClaimChecks, + ClaimKey (..), + ClaimPurpose (..), + newPurchaseChecks, + purchaseCheckWait, + maxPurchaseChecks, + PlayReads, + playReadWait, + maxPlayPurchaseReads, badgeErrorRetryAfter, + grantableCharge, + NotGranted (..), shownServiceRequest, survive, IssueCodeOpts (..), @@ -35,11 +49,11 @@ import BadgeService.Store import BadgeService.Store.Invoices (seedCatalog, truncateToSecond) import BadgeService.Store.Migrate (runBadgeServiceMigrations) import BadgeService.StoreReceipts -import BadgeService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction) -import BadgeService.StoreReceipts.Google (playStoreVerifier) -import BadgeService.StoreReceipts.Mock (mockStoreVerifier) +import BadgeService.StoreReceipts.Apple (appleSubscriptionChecker, readAppleRoot, verifyAppleTransaction) +import BadgeService.StoreReceipts.Google (PlayVerifier (..), playStoreVerifier) +import BadgeService.StoreReceipts.Mock (newMockStoreVerifier) import BadgeService.Waiters (Waiters, newWaiters) -import BadgeService.Web.Server (exportWebapp, newWebEnv, runWebListener) +import BadgeService.Web.Server (Bucket, exportWebapp, newWebEnv, runWebListener, takeFrom) import Control.Applicative (optional) import Control.Concurrent.STM import Control.Monad @@ -52,22 +66,23 @@ import Data.Either (fromRight) import Data.Functor (($>), (<&>)) import Data.Int (Int64) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, maybeToList) +import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) import qualified Data.Text as T -import Data.Time.Clock (UTCTime, getCurrentTime) +import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, 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.StorePlan (storePlan) 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.PaymentService (ServicePayment (..)) -import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..)) +import Simplex.Chat.PaymentService (AppleTransactionFacts (..), ServicePayment (..), appleTransactionFacts) +import Simplex.Chat.PaymentService.Types (PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), User (..)) @@ -126,7 +141,7 @@ checkOne ChatConfig {badgePublicKeys} k@BadgeIssuerKey {keyIdx, secretKey} = requireIssuerKey :: BadgeServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO BadgeIssuerKey requireIssuerKey opts serviceCfg cfg = - checkIssuerKey opts serviceCfg cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure + checkIssuerKey opts serviceCfg cfg >>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) (\k@BadgeIssuerKey {keyIdx} -> logInfo ("signing badges with issuer key " <> tshow keyIdx) $> k) readConfigOrExit :: FilePath -> IO ServiceConfig readConfigOrExit path = @@ -134,30 +149,47 @@ readConfigOrExit path = Left e -> putStrLn (path <> ": " <> e) >> exitFailure Right sc -> pure sc --- | The config refuses the dev flag beside a real verifier, so only one of them applies. +-- | Only one of the mock and the real verifiers applies: the config refuses +-- accept_unverified_store_receipts beside [apple] or [google]. The other two dev settings, +-- credit_test_purchases and check_store_during_coverage, are meant to sit beside a real verifier. configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState -configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case serviceCfg of - Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True} -> do +configureStoreVerifier serviceCfg env@ServiceState {serviceCC, storeVerifier = v} = case serviceCfg of + Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True, devCheckStoreDuringCoverage} -> do logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" - pure env {storeVerifier = mockStoreVerifier} - Just ServiceConfig {appleStore, playStore} -> do - apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} -> - (\roots -> verifyAppleTransaction roots aBundleId) <$> mapM (orExit . readAppleRoot) aRootCertificates + -- the badge clock is read at each check, as the controller that holds it starts after this + mock <- newMockStoreVerifier $ atomically (readTMVar serviceCC) >>= badgeNow + pure env {storeVerifier = mock {storeChecks = if devCheckStoreDuringCoverage then ChecksAtEveryRequest else ChecksAfterCoverage}} + Just ServiceConfig {appleStore, playStore, devCheckStoreDuringCoverage, devCreditTestPurchases} -> do + when devCreditTestPurchases $ logWarn "[dev] credit_test_purchases is on: a purchase the store calls a test is credited as a real one" + apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates, aServerApi} -> do + roots <- mapM (orExit . readAppleRoot) aRootCertificates + appleSubscriptions <- forM aServerApi $ orExit . appleSubscriptionChecker roots aBundleId + pure (verifyAppleTransaction roots aBundleId, appleSubscriptions) google <- forM playStore $ orExit . playStoreVerifier - forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId + forM_ appleStore $ \AppleStoreConfig {aBundleId, aServerApi} -> + logInfo $ "verifying App Store receipts for " <> aBundleId <> maybe ", but not its subscriptions: [apple] sets no API key" (const "") aServerApi forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName pure env { storeVerifier = v - { verifyApple = maybe (verifyApple v) Just apple, - verifyGoogle = maybe (verifyGoogle v) (Just . fst) google, - acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . snd) google + { verifyApple = maybe (verifyApple v) (Just . fst) apple, + verifyGoogle = maybe (verifyGoogle v) (Just . verifyPlayPurchase) google, + acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . acknowledgePlayPurchase) google, + acknowledgeGoogleSubscription = maybe (acknowledgeGoogleSubscription v) (Just . acknowledgePlaySubscription) google, + checkSubscription = case (snd =<< apple, checkPlaySubscription <$> google) of + (Nothing, Nothing) -> checkSubscription v + (checkApple_, checkGoogle_) -> Just $ \ref _ -> case ref of + SubscriptionApple originalId -> maybe notConfigured ($ originalId) checkApple_ + SubscriptionGoogle token -> maybe notConfigured ($ token) checkGoogle_, + storeChecks = if devCheckStoreDuringCoverage && devCreditTestPurchases then ChecksAtEveryRequest else ChecksAfterCoverage, + creditTestPurchases = devCreditTestPurchases } } Nothing -> pure env where orExit = (>>= either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure) + notConfigured = pure $ Left SRNotConfigured badgeService :: BadgeServiceOpts -> ChatConfig -> ServiceState -> IO () badgeService opts@BadgeServiceOpts {serviceConfigFile} cfg env = do @@ -289,22 +321,57 @@ issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO () processQueuedRequests key trackerQ_ env = do cc <- atomically $ readTMVar $ serviceCC env - -- a Play hand-over can wait two verifyTimeouts on Play, so it waits on a lane of its own - playQ <- newTQueueIO - let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData - route req@(_, _, _, reqData) - | presentsPlayReceipt reqData = atomically $ writeTQueue playQ req - | otherwise = answer req + -- a store call can take verifyTimeout, and a Play hand-over two, so a request that may make one waits on a lane of its own, and renewal checks on one no purchase can fill + purchaseQ <- newTQueueIO + renewalQ <- newTQueueIO + checks <- newPurchaseChecks + let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) checks cc trackerQ_ u reqId sigKey reqData + route req@(_, _, sigKey, reqData) = + requestLane (storeChecks $ storeVerifier env) cc sigKey reqData >>= \case + MainLane -> answer req + PurchaseLane -> atomically $ writeTQueue purchaseQ req + RenewalLane -> atomically $ writeTQueue renewalQ req lane q act = forever $ do req@(_, reqId, _, _) <- atomically $ readTQueue q survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req - raceAny_ [lane (serviceRequestQ env) route, lane playQ answer] + raceAny_ [lane (serviceRequestQ env) route, lane purchaseQ answer, lane renewalQ answer] --- | Only what creates a purchase leaves the main lane: an append to an existing ledger is guarded by nothing but that lane being serial. -presentsPlayReceipt :: J.Object -> Bool -presentsPlayReceipt reqData = case J.fromJSON (J.Object reqData) of - J.Success BadgeServiceRequest {request = BSCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> True - _ -> False +data RequestLane = MainLane | PurchaseLane | RenewalLane + deriving (Eq, Show) + +-- | Store calls wait off the main lane, so appends to one ledger come from more than one lane: each takes its +-- purchase's row lock before re-reading the tip, so the lanes may run concurrently at any --pool-size. +requestLane :: StoreChecks -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO RequestLane +requestLane checks cc sigKey reqData = case J.fromJSON (J.Object reqData) of + J.Success BadgeServiceRequest {request = BSCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> pure PurchaseLane + J.Success BadgeServiceRequest {request = BSCPurchaseBadge {payment = SPApple {jws}, upgrade = Nothing}} + | maybe False autoRenewable (appleTransactionFacts jws) -> pure PurchaseLane + J.Success BadgeServiceRequest {request = BSCTransferBadge {payment = SPApple {jws}}} + | maybe False autoRenewable (appleTransactionFacts jws) -> pure PurchaseLane + J.Success BadgeServiceRequest {request = BSCTransferBadge {payment = SPGoogle {}}} -> pure PurchaseLane + J.Success BadgeServiceRequest {purchaseKey = Just k, request = BSCIssueBadge {}} + | sigKey == Just k -> do + now <- badgeNow cc + withDB' "requestLane" cc (\db -> getIssuePurchase db k) <&> \case + -- a due month goes to the serial renewal lane even within the hour, so it queues behind a check still in flight + Right (Just (_, Just tip, Just _)) + | checks == ChecksAtEveryRequest || balanceStartTs tip <= addUTCTime routeLookahead now -> RenewalLane + Right _ -> MainLane + -- a read that failed may hide a due check, and the main lane is what must not wait on a store + Left _ -> RenewalLane + J.Success BadgeServiceRequest {purchaseKey = Just k, request = BSCUpgradeBadgeSubscription {}} + | sigKey == Just k -> do + now <- badgeNow cc + withDB' "requestLane" cc (\db -> getIssuePurchase db k) <&> \case + Right (Just (_, Just _, Just subscription@PurchaseSubscription {subscriptionRef = SubscriptionApple _})) + | isNothing $ checkWait (addUTCTime routeLookahead now) subscription -> RenewalLane + Right _ -> MainLane + Left _ -> RenewalLane + _ -> pure MainLane + +-- | The router judges a minute ahead, so a handler reading the clock moments later finds due only what the router routed off the main lane. +routeLookahead :: NominalDiffTime +routeLookahead = 60 -- | Cancellation is rethrown, since that is how the race stops this thread. The -- exception itself is not logged: it can quote the request, which carries codes and store receipts. @@ -326,13 +393,14 @@ badgePostStartHook BadgeServiceOpts {noAddress, testing} env cc = do unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc -handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () -handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do +handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> PurchaseChecks -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () +handleServiceRequest key verifier checks cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "badge service request " <> reqIdT resp <- - badgeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ -> + badgeServiceResponse key verifier checks cc trackerQ_ sigKey reqData `catchOwn'` \_ -> logError ("badge service request " <> reqIdT <> " failed") $> errorResponse BSEInternal + logInfo $ "badge answer: " <> responseName resp sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "badge service response failed for " <> reqIdT <> ": " <> tshow e @@ -354,25 +422,36 @@ badgeErrorRetryAfter = \case -- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for. -badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse -badgeServiceResponse key verifier cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of +badgeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> PurchaseChecks -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO BadgeServiceResponse +badgeServiceResponse key verifier checks cc trackerQ_ 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 + | otherwise -> logInfo ("badge request: " <> commandName request) >> case request of BSCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc trackerQ_ k masterKey code Nothing -> pure $ errorResponse BSEBadRequest BSCPurchaseBadge {masterKey, payment, upgrade} | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of - (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_ + (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc verifier checks RUPurchase k masterKey) receipt_ -- store upgrades are not built, and ignoring one would credit its months at the discounted price (Just _, Just _) -> pure $ errorResponse BSEBadRequest (Nothing, _) -> pure $ errorResponse BSEBadRequest BSCIssueBadge {balance} -> case purchaseKey of - Just k -> issueBadgeCmd key cc k balance + Just k -> issueBadgeCmd key cc verifier k Nothing balance Nothing -> pure $ errorResponse BSEBadRequest + -- only an App Store subscription changes tier on the same transaction; Play gives a change a new token + BSCUpgradeBadgeSubscription {payment = payment@SPApple {}, balance} + | Just k <- purchaseKey, + Just receipt_ <- toStoreReceipt verifier payment -> + either storeRefusalResponse (upgradeSubscriptionCmd key cc verifier k balance) receipt_ + BSCUpgradeBadgeSubscription {} -> pure $ errorResponse BSEBadRequest + BSCTransferBadge {masterKey, payment} + | Just k <- purchaseKey, + Just receipt_ <- toStoreReceipt verifier payment -> + either storeRefusalResponse (purchaseWithReceipt key cc verifier checks (RUTransfer $ presentedTransaction payment) k masterKey) receipt_ + BSCTransferBadge {} -> pure $ errorResponse BSEBadRequest -- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase. _ -> case purchaseKey of Nothing -> pure $ errorResponse BSEUnsupportedVersion @@ -397,9 +476,9 @@ credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry 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}} +credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> Maybe SubscriptionState -> [StatementEntry] -> BadgeServiceResponse +credentialResponse credential previousEntryId subscription entries = + BSPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}, subscription} -- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent. redeemCode :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO BadgeServiceResponse @@ -421,7 +500,7 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode readCode now code db >>= \case Left resp -> pure (resp, False) Right _ -> logError "badge service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse BSEInternal, False) - Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth + Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing Nothing firstMonth let (resp, claimed) = fromRight (errorResponse BSEInternal, False) r when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code pure resp @@ -434,7 +513,7 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode -- A key that already redeemed gets its credential back without a use, even if the code has since -- expired or been revoked: a client whose reply was lost retries, and would otherwise lose the badge. KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> - maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing Nothing) <$> getLedgerEntries db badgePurchaseId 0 -- code_used would make the client drop its keys, so the holder could never get the badge back. KeyRedeemedUnreadable -> @@ -448,15 +527,28 @@ redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse BSECodeExpired | otherwise -> pure $ Right c +-- | What a store receipt is presented for. A transfer carries the Apple transaction the device presented, +-- as only the subscription's latest one moves it. +data ReceiptUse = RUPurchase | RUTransfer (Maybe T.Text) + +presentedTransaction :: ServicePayment -> Maybe T.Text +presentedTransaction = \case + SPApple {jws} -> (\AppleTransactionFacts {transactionId} -> transactionId) <$> appleTransactionFacts jws + _ -> Nothing + -- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and -- nothing is written until the credential is signed. -purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO BadgeServiceResponse -purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt, acknowledgeReceipt} = - withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case +purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> StoreVerifier -> PurchaseChecks -> ReceiptUse -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO BadgeServiceResponse +purchaseWithReceipt key cc verifier checks use purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, receiptProductId, subscriptionRef, verifyReceipt, acknowledgeReceipt, acknowledgeSubscription} = + withDB' "getStorePayment" cc (\db -> (,) <$> getStorePaymentCredit db provider providerRef <*> keyHoldsPurchase db) >>= \case Left _ -> pure $ errorResponse BSEInternal - Right credit + Right (credit, keyHeld) -- only the key it credited can ask, and it is told only what it was given, so the store is not asked | creditedBy credit -> answerCredit credit + -- a payment moves only to a key with no purchase, so one transferOut alone names the purchase holding it + | keyHeld -> pure $ errorResponse BSEBadRequest + -- a Play subscription is verified by asking about the subscription itself + | Just StoreSubscription <- storeProduct provider =<< receiptProductId -> purchaseSubscription receiptProductId credit | otherwise -> verifyReceipt >>= \case Left refusal -> storeRefusalResponse refusal @@ -465,12 +557,20 @@ purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTran | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" Right VerifiedStoreTransaction {quantity} | quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity - Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse SRTestPurchase + Right VerifiedStoreTransaction {testPurchase = True} + | not (creditTestPurchases verifier) -> storeRefusalResponse SRTestPurchase -- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited - Right tx -> case credit of + Right tx@VerifiedStoreTransaction {productId} -> case credit of Uncredited -> newPurchase tx + _ + | RUTransfer _ <- use, + Just StoreSubscription <- storeProduct provider productId -> + purchaseSubscription (Just productId) credit _ -> answerCredit credit where + keyHoldsPurchase db = case use of + RUPurchase -> pure False + RUTransfer _ -> purchaseKeyExists db purchaseKey creditedBy = \case Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey _ -> False @@ -479,32 +579,222 @@ purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTran Right (Left resp) -> resp _ -> errorResponse BSEInternal -- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered - newPurchase tx@VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of + newPurchase tx@VerifiedStoreTransaction {productId, paid, testPurchase} = case storeProduct provider productId of Nothing -> pure $ errorResponse BSEProductUnavailable - Just StoreProduct {badgeType, months} -> - maybe (pure $ Right ()) ($ tx) acknowledgeReceipt >>= \case - Left refusal -> storeRefusalResponse refusal - Right () -> do - now <- badgeNow cc - signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case + -- an Apple subscription, whose JWS was verified offline + Just StoreSubscription -> purchaseSubscription (Just productId) Uncredited + Just StoreOneTime {badgeType, months} -> do + when testPurchase $ logWarn "[dev] crediting a store test purchase as paid: credit_test_purchases is on" + acknowledged (($ tx) <$> acknowledgeReceipt) $ do + now <- badgeNow cc + signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case + Left resp -> pure resp + Right firstMonth -> + claimPurchase now (\paymentId -> NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType, subscriptionState = Nothing, providerToken = Nothing}) $ + \db purchaseId paymentId -> firstMonthResponse db purchaseId (Just paymentId) Nothing firstMonth + -- another key's credit is told only once the store vouched for the subscription, as for a one-time purchase; + -- the store is asked at most once an interval per subscription and per purpose, whoever presents it + purchaseSubscription product_ credit = do + now <- badgeNow cc + atomically (subscriptionAsk now) >>= \case + ClaimHeld remaining -> pure BSPError {code = BSEPaymentPending, message = Nothing, retryAfter = Just (ceiling remaining)} + NoPlayRead wait -> do + logWarn "Play subscription read refused: the purchase path's reads for this minute are used" + pure BSPError {code = BSERateLimited, message = Nothing, retryAfter = Just (ceiling wait)} + AskStore -> + checkStoreSubscription verifier subscriptionRef product_ >>= \case + -- no store was asked, so the claim is not held off + Left SRNotConfigured -> atomically (modifyTVar' claimChecks $ M.delete claimKey) >> storeRefusalResponse SRNotConfigured + Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal + Right checked -> atLeastCheckInterval <$> case (credit, use) of + (Uncredited, _) -> newSubscription checked + (Credited from, RUTransfer presented) -> moveSubscription presented from checked + _ -> answerCredit credit + subscriptionAsk now = + purchaseCheckWait claimChecks now claimKey >>= \case + Just remaining -> pure $ ClaimHeld remaining + Nothing -> + maybe (pure Nothing) (`playReadWait` now) playReads_ >>= \case + Nothing -> pure AskStore + -- a claim refused a read is not held, as Play was not asked + Just wait -> modifyTVar' claimChecks (M.delete claimKey) $> NoPlayRead wait + claimKey = ClaimKey {claimRef = providerRef, claimPurpose} + claimPurpose = case use of + RUPurchase -> ClaimPurchase + RUTransfer _ -> ClaimTransfer + (claimChecks, playReads_) = case subscriptionRef of + SubscriptionApple _ -> (appleChecks checks, Nothing) + SubscriptionGoogle _ -> (googleChecks checks, Just $ playReads checks) + newSubscription checked@SubscriptionCheck {subscriptionState} = do + now <- badgeNow cc + case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now Nothing of + Left notGranted -> firstChargeNotGranted provider notGranted + Right (charge@StoreCharge {periodStart, chargePaid = paid}, (badgeType, months)) -> + acknowledged acknowledgeSubscription $ do + chargeId <- randomId cc + entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc + let seed = emptyEntry periodStart badgeType + signMonthAfter key cc masterKey now seed (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds seed) >>= \case Left resp -> pure resp - Right firstMonth -> do - paymentId <- randomId cc - r <- withDB "writeStorePurchase" cc $ \db -> - liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case - -- Credited to another key, or to this one by a request that ran alongside it, while signing. - Nothing -> - liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case - Left resp -> pure resp - Right () -> logError ("badge service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse BSEInternal - Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth - pure $ fromRight (errorResponse BSEInternal) r + Right firstMonth -> + claimPurchase now (\paymentId -> NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType, subscriptionState = Just subscriptionState, providerToken}) $ + \db purchaseId paymentId -> do + insertSubscriptionCharge db paymentId chargeId charge now + firstMonthResponse db purchaseId Nothing (Just subscriptionState) firstMonth + moveSubscription presented CreditedPurchase {badgePurchaseId = fromPurchaseId} checked@SubscriptionCheck {subscriptionState, latestCharge} + -- an older transaction stops moving the subscription once the store charges again, so a leaked receipt lapses + | SubscriptionApple _ <- subscriptionRef, + presented /= ((\StoreCharge {chargeRef} -> chargeRef) <$> latestCharge) = + pure $ errorResponse BSEPaymentPending + | otherwise = do + now <- badgeNow cc + withDB' "getMovedPayment" cc (\db -> getPurchaseSubscription db fromPurchaseId >>= mapM (movedPayment db)) >>= \case + Right (Just (paymentId, movedFromExpiry_, Just fromTip)) + -- A store receipt proves control of the store account but is no one-time secret, so this is the only bound on moves: + -- waiting out the credential of the key the payment last moved from keeps each paid month with at most two keys. + | maybe False (now <) movedFromExpiry_ -> pure $ errorResponse BSETransferLimited + | otherwise -> case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now Nothing of + Left notGranted -> firstChargeNotGranted provider notGranted + Right grant -> movePayment now paymentId fromTip grant + _ -> pure $ errorResponse BSEInternal + where + movedPayment db PurchaseSubscription {paymentId} = (paymentId,,) <$> getMovedFromExpiry db paymentId <*> getLedgerTip db fromPurchaseId + movePayment now paymentId fromTip (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) = + withDB' "getSubscriptionCharge" cc (\db -> getSubscriptionCharge db paymentId chargeRef) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right recorded -> do + chargeId <- maybe (randomId cc) (pure . fst) recorded + entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc + (lapseId, transferId) <- (,) <$> randomId cc <*> randomId cc + let seed = emptyEntry periodStart badgeType + lapsed = lapseEntry now lapseId fromTip + fromRows = maybeToList lapsed <> [debitEntry now transferId (SDTransferOut purchaseKey) (fromMaybe fromTip lapsed)] + signMonthAfter key cc masterKey now seed (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds seed) >>= \case + Left resp -> pure resp + Right firstMonth -> do + r <- withDB "transferStorePurchase" cc $ \db -> liftIO $ + rebindStorePayment db PaymentRebind {paymentId, fromPurchaseId, fromTipId = entryId fromTip, fromRows, purchaseKey, masterKey, badgeType} now >>= \case + Nothing -> pure Nothing + Just purchaseId -> do + insertSubscriptionCharge db paymentId chargeId charge now + setSubscriptionState db paymentId subscriptionState now + Just <$> firstMonthResponse db purchaseId Nothing (Just subscriptionState) firstMonth + case r of + Right (Just resp) -> logInfo "store subscription moved to another key" $> resp + Right Nothing -> logError "badge service: a subscription moved, or its ledger changed, while its transfer was signed" $> errorResponse BSEInternal + Left _ -> pure $ errorResponse BSEInternal + providerToken = case subscriptionRef of + SubscriptionGoogle token -> Just token + SubscriptionApple _ -> Nothing + acknowledged acknowledge_ crediting = + fromMaybe (pure $ Right ()) acknowledge_ >>= \case + Left refusal -> storeRefusalResponse refusal + Right () -> crediting + claimPurchase now purchaseFor writeCredit = do + paymentId <- randomId cc + r <- withDB "writeStorePurchase" cc $ \db -> + liftIO (createStorePurchase db (purchaseFor paymentId) now) >>= \case + -- Credited to another key, or to this one by a request that ran alongside it, while signing. + Nothing -> + liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case + Left resp -> pure resp + Right () -> logError "badge service: a store payment exists but funds no purchase" $> errorResponse BSEInternal + Just purchaseId -> liftIO $ writeCredit db purchaseId paymentId + pure $ fromRight (errorResponse BSEInternal) r + +-- | Why a store charge grants nothing. +data NotGranted + = NGEnded + | NGNoCharge + | NGMalformedPeriod + | NGRevoked + | NGOffer + | NGUnknownPlan + | NGNotStarted + | NGPeriodOver + deriving (Show) + +-- | The grant rule of a first purchase and a renewal alike, but for whether a ledger row already references +-- the charge, which is the caller's to check. It grants the tier of the plan the store charged, which may not be the ledger's. +grantableCharge :: PaymentProvider -> UTCTime -> SubscriptionCheck -> Either NotGranted (StoreCharge, (BadgeType, Int)) +grantableCharge provider now SubscriptionCheck {subscriptionState, latestCharge} + | subscriptionState == SSEnded = Left NGEnded + | otherwise = case latestCharge of + Nothing -> Left NGNoCharge + -- the plan first: Play derives a period from the plan's length, so a plan it does not know has none + Just charge@StoreCharge {planId, periodStart, periodEnd, revoked, offer} -> case storePlan provider planId of + Nothing -> Left NGUnknownPlan + Just plan + | periodEnd <= periodStart -> Left NGMalformedPeriod + | revoked -> Left NGRevoked + | offer -> Left NGOffer + | now < periodStart -> Left NGNotStarted + | now >= periodEnd -> Left NGPeriodOver + | otherwise -> Right (charge, plan) + +-- | Play dates a period from its end less the plan's months, clipped at month ends, so the months the ledger grants +-- from that start can end before Play's period does: a charge still in its period may then fund no month now. +fundsMonthNow :: C.PublicKeyEd25519 -> UTCTime -> Maybe StatementEntry -> (StoreCharge, (BadgeType, Int)) -> Either NotGranted (StoreCharge, (BadgeType, Int)) +fundsMonthNow purchaseKey now tip_ grant@(StoreCharge {periodStart}, (badgeType, months)) + | isJust $ issueEntry now "" $ last $ tip : chargeEntries periodStart now badgeType months purchaseKey "" ("", "", "", "") tip = Right grant + | otherwise = Left NGPeriodOver + where + tip = fromMaybe (emptyEntry periodStart badgeType) tip_ + +-- | The store ended the subscription, or revoked the latest charge it reports, one this payment was granted: a refund or a reversal. +coverageEnded :: DB.Connection -> T.Text -> SubscriptionCheck -> IO Bool +coverageEnded db paymentId SubscriptionCheck {subscriptionState, latestCharge} + | subscriptionState == SSEnded = pure True + | Just StoreCharge {chargeRef, revoked = True} <- latestCharge = maybe False snd <$> getSubscriptionCharge db paymentId chargeRef + | otherwise = pure False + +-- | No payment row exists yet to stamp, so a first purchase's checks are bounded by the purchase checks. +firstChargeNotGranted :: PaymentProvider -> NotGranted -> IO BadgeServiceResponse +firstChargeNotGranted provider = \case + NGMalformedPeriod -> storeRefusalResponse $ SRVerifierFailed "a charge whose period does not end after its start" + -- Play issues a new token when an ended subscription is bought again, so this one can never be credited + NGEnded | PPGoogle <- provider -> storeRefusalResponse $ SRInvalid "the subscription ended" + NGNotStarted -> storeRefusalResponse SRPending + -- a later charge, or an Apple resubscription under the same original id, may yet be credited from this receipt + reason -> logInfo ("store subscription not credited yet: " <> tshow reason) $> BSPError {code = BSEPaymentPending, message = Nothing, retryAfter = Just 86400} + +-- | What each request was answered, to pair with its command in a log. No payload: a credential, +-- an invoice and a statement are the client's, and an error's message can quote what it refused. +responseName :: BadgeServiceResponse -> T.Text +responseName = \case + BSPBadgeCatalog {} -> "catalog" + BSPBadgeInvoice {} -> "invoice" + BSPBadgeCredential {credential = Just _, subscription} -> + "badge issued" <> maybe "" (\s -> ", subscription " <> tshow s) subscription + BSPBadgeCredential {credential = Nothing} -> "no month to issue" + BSPError {code} -> "error " <> tshow code + +-- | The command each request ran, so a log can be followed without the request ids. No payload: +-- a payment holds a store receipt, and an upgrade a receipt code. +commandName :: BadgeServiceCommand -> T.Text +commandName = \case + BSCGetBadgeCatalog -> "getBadgeCatalog" + BSCGetBadgeInvoice {} -> "getBadgeInvoice" + BSCRedeemBadgeCode {} -> "redeemBadgeCode" + BSCPurchaseBadge {payment} -> "purchaseBadge " <> paymentName payment + BSCUpgradeBadgeSubscription {payment} -> "upgradeBadgeSubscription " <> paymentName payment + BSCTransferBadge {payment} -> "transferBadge " <> paymentName payment + BSCIssueBadge {} -> "issueBadge" + BSCPauseBadge -> "pauseBadge" + where + paymentName = \case + SPApple {} -> "(App Store)" + SPGoogle {} -> "(Play)" + SPInvoice {} -> "(invoice)" + SPReceipt {} -> "(receipt)" -- | A forged receipt and a refunded one are both receipt_invalid, so the client learns nothing a -- forger could iterate on. A test purchase is named, since the device that made it already knows. storeRefusalResponse :: StoreRefusal -> IO BadgeServiceResponse storeRefusalResponse = \case - SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse BSEReceiptInvalid + -- warn, not info: every one of these ends a purchase for good, and one of them (a store that no longer + -- knows a subscription it once sold) can do that to a purchase the user really paid for + SRInvalid reason -> logWarn ("store receipt refused for good: " <> reason) $> errorResponse BSEReceiptInvalid SRTestPurchase -> logInfo "store receipt refused: test purchase" $> errorResponse BSETestPurchase SRPending -> pure $ errorResponse BSEPaymentPending SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse BSEProviderUnavailable @@ -515,68 +805,322 @@ creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO ( creditedResponse db purchaseKey = \case Uncredited -> pure $ Right () CreditedUnreadable -> pure $ Left $ errorResponse BSEInternal - Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} + Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential, subscription} | k /= purchaseKey -> pure $ Left $ errorResponse BSEReceiptUsed | otherwise -> - maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing) + maybe (Left $ errorResponse BSEInternal) (Left . credentialResponse (Just credential) Nothing subscription) <$> getLedgerEntries db badgePurchaseId 0 -signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) +signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either BadgeServiceResponse ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential))) signFirstMonth key cc masterKey badgeType months credit now = do - (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc + grantUuid <- randomId cc -- 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 credit $ 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 $ Left $ errorResponse BSEInternal + let seed = emptyEntry now badgeType + signMonthAfter key cc masterKey now seed [grantEntry now grantUuid badgeType months credit seed] + +-- | The rows that fund a month, after the entry they follow, with that month issued at now and signed. +signMonthAfter :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> UTCTime -> StatementEntry -> [StatementEntry] -> IO (Either BadgeServiceResponse ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential))) +signMonthAfter key cc masterKey now previous rows = do + issueUuid <- randomId cc + let funded = last (previous : rows) + case issueEntry now issueUuid funded of + Nothing -> logError "badge service: the rows written fund no month to issue" $> Left (errorResponse BSEInternal) Just issued -> credentialForEntry key masterKey issued >>= \case Left e -> logError ("badge service signing failed: " <> T.pack e) $> Left (errorResponse BSEInternal) - Right signed -> pure $ Right (granted, signed) + Right signed -> pure $ Right (rows, issuanceAfter funded signed) -firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse -firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do - appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed +firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> Maybe SubscriptionState -> ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential)) -> IO BadgeServiceResponse +firstMonthResponse db purchaseId creditPaymentId_ subscription (rows, issuance@(_, _, credential)) = do + appendLedgerPlan db purchaseId creditPaymentId_ rows $ Just issuance entries_ <- getLedgerEntries db purchaseId 0 - pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ + pure $ maybe (errorResponse BSEInternal) (credentialResponse (Just credential) Nothing subscription) entries_ + +-- | The subscription a device's transaction names, its product and that product's tier: what an upgrade waits for, never what it grants. +data SubscriptionUpgrade = SubscriptionUpgrade + { upgradeRef :: T.Text, + upgradeProductId :: T.Text, + upgradeTier :: BadgeType + } + +-- | Refused before the store is asked: a transaction that does not verify, or that is of no subscription product. +upgradeSubscriptionCmd :: BadgeIssuerKey -> ChatController -> StoreVerifier -> C.PublicKeyEd25519 -> BadgeBalance -> StoreReceipt -> IO BadgeServiceResponse +upgradeSubscriptionCmd key cc verifier purchaseKey balance StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = claimedRef}, verifyReceipt} = + verifyReceipt >>= \case + Left refusal -> storeRefusalResponse refusal + Right VerifiedStoreTransaction {transactionRef} + | transactionRef /= claimedRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" + Right VerifiedStoreTransaction {productId} -> case storePlan provider productId of + Nothing -> pure $ errorResponse BSEBadRequest + Just (tier, _) -> issueBadgeCmd key cc verifier purchaseKey (Just SubscriptionUpgrade {upgradeRef = claimedRef, upgradeProductId = productId, upgradeTier = tier}) balance -- | 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 +issueBadgeCmd :: BadgeIssuerKey -> ChatController -> StoreVerifier -> C.PublicKeyEd25519 -> Maybe SubscriptionUpgrade -> BadgeBalance -> IO BadgeServiceResponse +issueBadgeCmd key cc verifier purchaseKey upgrade_ 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 + purchase_ <- withDB' "getBadgePurchase" cc (\db -> getIssuePurchase db purchaseKey) case purchase_ of Left _ -> pure $ errorResponse BSEInternal Right Nothing -> pure $ errorResponse BSEUnknownPurchaseKey - Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do + Right (Just (purchase, Just tip, Just subscription)) + | Just upgrade <- upgrade_ -> upgradeSubscription upgrade purchase tip subscription now + -- once a subscription's paid coverage has ended, only a new store charge funds another month + | paidThrough tip <= now -> renewSubscription Nothing purchase tip subscription now + | ChecksAtEveryRequest <- storeChecks verifier -> checkStoreDuringCoverage purchase tip subscription now + | balanceStartTs tip <= now -> checkStoreBeforeMonth purchase tip subscription now + Right (Just _) | isJust upgrade_ -> pure $ errorResponse BSEBadRequest + Right (Just (ServicePurchase {badgePurchaseId}, Just StatementEntry {entryType = SEDebit SDTransferOut {}}, Nothing)) -> + answered "answerMovedOut" $ \db -> withoutCredential <$> issueResponse db badgePurchaseId now (Just SSEnded) + Right (Just (purchase, tip, subscription_)) -> issueDue purchase tip ((\PurchaseSubscription {storedState} -> storedState) <$> subscription_) now + where + issueDue ServicePurchase {badgePurchaseId, masterKey, badgeType} tip state_ now = 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 + Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing state_ 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 + writeIssued badgePurchaseId tip (maybeToList lapsed) now (Just $ issuanceAfter current signed) state_ -- Write only if the tip has not moved, since another request may have advanced it. - writeIssued purchaseId tip rows t issuance_ = do + writeIssued purchaseId tip rows t issuance_ state_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do - tip' <- getLedgerTip db purchaseId + tip' <- lockedLedgerTip db purchaseId t when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_ - issueResponse db purchaseId t + issueResponse db purchaseId t state_ pure $ fromRight (errorResponse BSEInternal) r + -- Answered pending until the tier the device saw is granted, so the old tier's credential is never taken for the new one. + upgradeSubscription upgrade@SubscriptionUpgrade {upgradeRef, upgradeTier} purchase@ServicePurchase {badgePurchaseId} tip@StatementEntry {balanceBadgeType} subscription@PurchaseSubscription {subscriptionRef, storedState} now + | not ownSubscription = pure $ errorResponse BSEBadRequest + | balanceBadgeType == upgradeTier = answered "answerUpgraded" $ \db -> issueResponse db badgePurchaseId now (Just storedState) + | Just wait <- checkWait now subscription = + pure BSPError {code = BSEPaymentPending, message = Nothing, retryAfter = Just $ ceiling wait} + | otherwise = renewSubscription (Just upgrade) purchase tip subscription now + where + ownSubscription = case subscriptionRef of + SubscriptionApple ref -> ref == upgradeRef + SubscriptionGoogle _ -> False + -- Nothing but the store's answer is written until a charge is granted: a lapse at now would be dated + -- after a grant a later check may make at an earlier period start, and the client would read it as a pass that issued. + renewSubscription upgrading_ ServicePurchase {badgePurchaseId, masterKey} tip@StatementEntry {balanceBadgeType} subscription@PurchaseSubscription {paymentId, provider, subscriptionRef, storedState} now + | isNothing upgrading_ && not (subscriptionCheckDue (storeChecks verifier) now tip subscription) = answered "answerStoredSubscription" $ \db -> issueResponse db badgePurchaseId now (Just storedState) + | otherwise = + withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right () -> + -- the stores ignore the raise's product; the development mock charges its plan from now on + checkStoreSubscription verifier subscriptionRef (upgradeProductId <$> upgrading_) >>= \case + Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal + Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now (Just tip) of + Left NGMalformedPeriod -> storeRefusalResponse $ SRVerifierFailed "a charge whose period does not end after its start" + Left reason -> do + forM_ latestCharge $ \_ -> logInfo $ "store subscription charge not granted: " <> tshow reason + recordCheck latestCharge subscriptionState + Right (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) -> + withDB' "getSubscriptionCharge" cc (\db -> getSubscriptionCharge db paymentId chargeRef) >>= \case + Left _ -> pure $ errorResponse BSEInternal + -- the store still reports the charge the ledger last granted + Right (Just (_, True)) -> recordCheck (Just charge) subscriptionState + Right recorded -> do + chargeId <- maybe (randomId cc) (pure . fst) recorded + entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc + signMonthAfter key cc masterKey now tip (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds tip) >>= \case + Left resp -> pure resp + Right (rows, issuance) -> do + r <- withDB "renewSubscription" cc $ \db -> liftIO $ do + tip' <- lockedLedgerTip db badgePurchaseId now + if fmap entryId tip' /= Just (entryId tip) + then pure Nothing + else do + insertSubscriptionCharge db paymentId chargeId charge now + appendLedgerPlan db badgePurchaseId Nothing rows $ Just issuance + setPurchaseBadgeType db badgePurchaseId badgeType now + setSubscriptionState db paymentId subscriptionState now + Just <$> checkedResponse db badgeType subscriptionState + case r of + Right (Just resp) -> logBanked months $> resp + Right Nothing -> logError "badge service: a subscription's ledger moved while its renewal was signed" $> errorResponse BSEInternal + Left _ -> pure $ errorResponse BSEInternal + where + -- the store's answer is kept, and a charge it reports is recorded once, granted or not + recordCheck charge_ state = do + chargeId <- randomId cc + answered "recordSubscriptionCheck" $ \db -> do + forM_ charge_ $ \charge -> insertSubscriptionCharge db paymentId chargeId charge now + setSubscriptionState db paymentId state now + checkedResponse db balanceBadgeType state + checkedResponse db ledgerTier state = case upgradeTier <$> upgrading_ of + Just tier | tier /= ledgerTier -> pure $ atLeastCheckInterval $ errorResponse BSEPaymentPending + _ -> issueResponse db badgePurchaseId now (Just state) + -- A charge a dev deployment's store reports while coverage runs is banked with no issuance, and its month issued once coverage reaches it. + checkStoreDuringCoverage purchase@ServicePurchase {badgePurchaseId} tip PurchaseSubscription {paymentId, provider, subscriptionRef} now = + withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right () -> + checkStoreSubscription verifier subscriptionRef Nothing >>= \case + Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal + Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> do + newChargeId <- randomId cc + entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc + r <- withDB' "checkStoreDuringCoverage" cc $ \db -> do + tip' <- lockedLedgerTip db badgePurchaseId now + ended <- coverageEnded db paymentId checked + banked <- + if not ended && fmap entryId tip' == Just (entryId tip) + then do + bankedMonths <- case grantableCharge provider now checked of + Right (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) -> + getSubscriptionCharge db paymentId chargeRef >>= \case + Just (_, True) -> pure Nothing + recorded -> do + let chargeId = maybe newChargeId fst recorded + insertSubscriptionCharge db paymentId chargeId charge now + appendLedgerPlan db badgePurchaseId Nothing (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds tip) Nothing + setPurchaseBadgeType db badgePurchaseId badgeType now + pure $ Just months + Left _ -> Nothing <$ forM_ latestCharge (\charge -> insertSubscriptionCharge db paymentId newChargeId charge now) + setSubscriptionState db paymentId subscriptionState now + pure bankedMonths + else pure Nothing + (ended, banked,) <$> getLedgerTip db badgePurchaseId + case r of + Left _ -> pure $ errorResponse BSEInternal + Right (True, _, _) -> refundRemaining purchase tip paymentId now + Right (False, banked, tip_) -> do + forM_ banked logBanked + issueDue purchase tip_ (Just subscriptionState) now + -- While coverage runs the store is asked before each new month, so a refund ends the months not yet issued; a store + -- that fails or does not answer ends nothing, and the month is issued as before. + checkStoreBeforeMonth purchase tip subscription@PurchaseSubscription {paymentId, subscriptionRef, storedState} now + | held, storedState == SSEnded = refundRemaining purchase tip paymentId now + | held = issueDue purchase (Just tip) (Just storedState) now + | otherwise = + withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case + Left _ -> pure $ errorResponse BSEInternal + Right () -> + checkStoreSubscription verifier subscriptionRef Nothing >>= \case + Left refusal -> storeRefusalResponse refusal >> issueDue purchase (Just tip) (Just storedState) now + Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> do + chargeId <- randomId cc + r <- withDB' "recordSubscriptionCheck" cc $ \db -> do + ended <- coverageEnded db paymentId checked + -- a new charge is only recorded: it is granted once coverage ends, as a renewal + unless ended $ forM_ latestCharge $ \charge -> insertSubscriptionCharge db paymentId chargeId charge now + setSubscriptionState db paymentId (if ended then SSEnded else subscriptionState) now + pure ended + case r of + Left _ -> pure $ errorResponse BSEInternal + Right True -> refundRemaining purchase tip paymentId now + Right False -> issueDue purchase (Just tip) (Just subscriptionState) now + where + held = isJust $ checkWait now subscription + refundRemaining ServicePurchase {badgePurchaseId} tip paymentId now = do + (lapseId, refundId) <- (,) <$> randomId cc <*> randomId cc + let lapsed = lapseEntry now lapseId tip + refund@StatementEntry {changeMonths = refunded} = debitEntry now refundId SDRefund (fromMaybe tip lapsed) + r <- withDB' "refundSubscription" cc $ \db -> do + tip' <- lockedLedgerTip db badgePurchaseId now + if fmap entryId tip' /= Just (entryId tip) + then pure Nothing + else do + -- with no months left there is nothing to refund, as when an earlier request already refunded them + unless (refunded == 0) $ appendLedgerPlan db badgePurchaseId Nothing (maybeToList lapsed <> [refund]) Nothing + setSubscriptionState db paymentId SSEnded now + Just . withoutCredential <$> issueResponse db badgePurchaseId now (Just SSEnded) + case r of + Right (Just resp) -> resp <$ unless (refunded == 0) (logInfo $ "store subscription ended during coverage: " <> tshow (negate refunded) <> " months refunded") + Right Nothing -> logError "badge service: a subscription's ledger moved while its refund was written" $> errorResponse BSEInternal + Left _ -> pure $ errorResponse BSEInternal + answered what action = fromRight (errorResponse BSEInternal) <$> withDB' what cc action + withoutCredential = \case + BSPBadgeCredential {receipt, statement, subscription} -> BSPBadgeCredential {credential = Nothing, receipt, statement, subscription} + resp -> resp + logBanked months = logInfo $ "store subscription charge banked: " <> tshow months <> " months" -- Only the asserted entry's identity is read, never the months it claims. - issueResponse db purchaseId t = do + issueResponse db purchaseId t subscription = 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, 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_ + pure $ maybe (errorResponse BSEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId) subscription) entries_ + +getIssuePurchase :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe (ServicePurchase, Maybe StatementEntry, Maybe PurchaseSubscription)) +getIssuePurchase db purchaseKey = do + p_ <- getPurchaseByKey db purchaseKey + forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,,) <$> getLedgerTip db badgePurchaseId <*> getPurchaseSubscription db badgePurchaseId + +-- | Due with each month, within coverage too, so a refund ends the months not yet issued; at most once an interval. +subscriptionCheckDue :: StoreChecks -> UTCTime -> StatementEntry -> PurchaseSubscription -> Bool +subscriptionCheckDue ChecksAtEveryRequest _ _ _ = True +subscriptionCheckDue ChecksAfterCoverage now tip subscription = + balanceStartTs tip <= now && isNothing (checkWait now subscription) + +-- | What remains of the interval since the subscription last asked its store, if anything. +checkWait :: UTCTime -> PurchaseSubscription -> Maybe NominalDiffTime +checkWait now PurchaseSubscription {checkedAt} = + mfilter (> 0) $ (\t -> subscriptionCheckInterval - diffUTCTime now t) <$> checkedAt + +-- | Each subscription asks its store at most this often, failed attempts included, so a client +-- repeating its requests cannot spend the store quota every subscriber shares. +subscriptionCheckInterval :: NominalDiffTime +subscriptionCheckInterval = 3600 + +-- | The next check waits out the interval whatever the client is told, so its retry waits as long. +atLeastCheckInterval :: BadgeServiceResponse -> BadgeServiceResponse +atLeastCheckInterval = \case + BSPError {code, message, retryAfter = Just t} -> BSPError {code, message, retryAfter = Just $ max t $ round subscriptionCheckInterval} + resp -> resp + +-- | The subscription claims the purchase path asked a store about, one map per store: the App Store's by original +-- transaction id, Play's by the token's hash, so Play tokens, which cost nothing to make up, cannot fill the App Store's; +-- and the minute's allowance of Play reads. +data PurchaseChecks = PurchaseChecks {appleChecks :: ClaimChecks, googleChecks :: ClaimChecks, playReads :: PlayReads} + +type ClaimChecks = TVar (M.Map ClaimKey UTCTime) + +-- | A claim is held per subscription and per what its receipt was presented for, so a restore on another +-- device is not held off by the purchase the buyer made minutes earlier, and each still asks at most once an interval. +data ClaimKey = ClaimKey {claimRef :: T.Text, claimPurpose :: ClaimPurpose} + deriving (Eq, Ord) + +data ClaimPurpose = ClaimPurchase | ClaimTransfer + deriving (Eq, Ord) + +-- | The Play subscription checks the purchase path made in the current minute, counted as the web listener counts requests. +type PlayReads = TVar (Maybe Bucket) + +newPurchaseChecks :: IO PurchaseChecks +newPurchaseChecks = PurchaseChecks <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO Nothing + +-- | How long a claim waits before its store is asked again; Nothing records a check at now. A claim that finds +-- the table full of checks inside the interval is asked without being recorded, so a full table refuses no one. +purchaseCheckWait :: ClaimChecks -> UTCTime -> ClaimKey -> STM (Maybe NominalDiffTime) +purchaseCheckWait checks now key = stateTVar checks $ \m -> + let live = M.filter (\t -> diffUTCTime now t < subscriptionCheckInterval) m + in case M.lookup key live of + Just t -> (Just $ subscriptionCheckInterval - diffUTCTime now t, live) + Nothing + | M.size live >= maxPurchaseChecks -> (Nothing, live) + | otherwise -> (Nothing, M.insert key now live) + +-- | Per store, far above an hour of genuine purchases: every App Store key is one Apple signed for this app, +-- while Play keys cost nothing to make up, so a full Play map only stops recording Play claims. +maxPurchaseChecks :: Int +maxPurchaseChecks = 10000 + +data SubscriptionAsk = AskStore | ClaimHeld NominalDiffTime | NoPlayRead NominalDiffTime + +-- | How long until the purchase path may read Play again; Nothing takes one of this minute's reads. +playReadWait :: PlayReads -> UTCTime -> STM (Maybe NominalDiffTime) +playReadWait taken now = stateTVar taken $ \b_ -> either (\wait -> (Just wait, b_)) (\b -> (Nothing, Just b)) (takeFrom maxPlayPurchaseReads now b_) + +-- | A tenth of Play's default Subscriptions quota of 3,000 reads a minute, which renewal checks share: any token in the +-- accepted grammar costs a read, so this caps what made-up tokens can spend; far above a minute of genuine purchases. +maxPlayPurchaseReads :: Int +maxPlayPurchaseReads = 300 diff --git a/apps/simplex-badge-service/src/BadgeService/Store.hs b/apps/simplex-badge-service/src/BadgeService/Store.hs index 6d238e78f4..cec4de216a 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store.hs @@ -25,6 +25,8 @@ module BadgeService.Store getStorePaymentCredit, purchaseKeyExists, getPurchaseByKey, + setPurchaseBadgeType, + lockedLedgerTip, getLedgerTip, getLedgerEntryId, getLedgerEntries, @@ -32,6 +34,15 @@ module BadgeService.Store appendLedgerPlan, createCodePurchase, createStorePurchase, + PaymentRebind (..), + rebindStorePayment, + getMovedFromExpiry, + PurchaseSubscription (..), + getPurchaseSubscription, + stampSubscriptionCheck, + setSubscriptionState, + getSubscriptionCharge, + insertSubscriptionCharge, insertBadgeCode, setCodeGroupItem, CodeTracker (..), @@ -43,6 +54,7 @@ module BadgeService.Store where import BadgeService.Store.Invoices (executeChanging, paymentStatusText) +import BadgeService.StoreReceipts (StoreCharge (..), SubscriptionRef (..)) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Lazy.Char8 as LB @@ -52,14 +64,14 @@ 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 (StatementCreditType (..), StatementEntry (..), StatementEntryType (..)) +import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), SubscriptionState) import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus, BadgePurchaseStatus (..)) -import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId (..), PaymentProvider, PaymentStatus (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), InvoiceId (..), PaymentProvider (..), PaymentStatus (..)) import Simplex.Chat.Store.Shared (insertedRowId) 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 (textEncode) +import Simplex.Messaging.Encoding.String (textDecode, textEncode) import Simplex.Messaging.Util (maybeFirstRow, maybeFirstRow') #if defined(dbPostgres) @@ -99,7 +111,8 @@ data FundingCredit data CreditedPurchase = CreditedPurchase { badgePurchaseId :: Int64, purchaseKey :: C.PublicKeyEd25519, - credential :: BadgeCredential + credential :: BadgeCredential, + subscription :: Maybe SubscriptionState } data NewCodePurchase = NewCodePurchase @@ -116,7 +129,9 @@ data NewStorePurchase = NewStorePurchase paid :: Maybe (CurrencyAmount, Text), purchaseKey :: C.PublicKeyEd25519, masterKey :: BadgeMasterKey, - badgeType :: BadgeType + badgeType :: BadgeType, + subscriptionState :: Maybe SubscriptionState, + providerToken :: Maybe Text -- Play's token of a subscription, which every renewal check asks by; never logged or returned } data ServicePurchase = ServicePurchase @@ -192,7 +207,7 @@ getCodePurchaseForKey db badgeCodeId key = FROM sx_badge_service_badge_purchases p LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id WHERE p.badge_code_id = ? AND p.purchase_key = ? - ORDER BY i.period_end DESC + ORDER BY i.entry_id DESC, i.period_end DESC LIMIT 1 |] (badgeCodeId, key) @@ -207,18 +222,18 @@ getStorePaymentCredit db provider providerRef = DB.query db [sql| - SELECT p.badge_purchase_id, p.purchase_key, i.credential + SELECT p.badge_purchase_id, p.purchase_key, i.credential, pay.subscription_state FROM sx_badge_service_payments pay JOIN sx_badge_service_badge_purchases p ON p.payment_id = pay.payment_id LEFT JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = p.badge_purchase_id WHERE pay.provider = ? AND pay.provider_ref = ? - ORDER BY i.period_end DESC + ORDER BY i.entry_id DESC, i.period_end DESC LIMIT 1 |] (textEncode provider, providerRef) where - toCredit (badgePurchaseId, purchaseKey, credential_) = case decodeCredential =<< credential_ of - Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential} + toCredit (badgePurchaseId, purchaseKey, credential_, state_) = case decodeCredential =<< credential_ of + Just credential -> Credited CreditedPurchase {badgePurchaseId, purchaseKey, credential, subscription = textDecode =<< state_} Nothing -> CreditedUnreadable decodeCredential :: Binary ByteString -> Maybe BadgeCredential @@ -244,6 +259,18 @@ getPurchaseByKey db key = toPurchase (badgePurchaseId, Binary mk, badgeType) = ServicePurchase {badgePurchaseId, masterKey = BadgeMasterKey mk, badgeType} +-- | In the transaction that writes the ledger rows of that tier. +setPurchaseBadgeType :: DB.Connection -> Int64 -> BadgeType -> UTCTime -> IO () +setPurchaseBadgeType db purchaseId badgeType now = + DB.execute db "UPDATE sx_badge_service_badge_purchases SET current_badge_type = ?, updated_at = ? WHERE badge_purchase_id = ?" (badgeType, now, purchaseId) + +-- | First in a transaction that appends after the tip it returns: the row lock makes concurrent appends to one +-- ledger wait for each other, as Postgres reads at READ COMMITTED. +lockedLedgerTip :: DB.Connection -> Int64 -> UTCTime -> IO (Maybe StatementEntry) +lockedLedgerTip db purchaseId now = do + DB.execute db "UPDATE sx_badge_service_badge_purchases SET updated_at = ? WHERE badge_purchase_id = ?" (now, purchaseId) + getLedgerTip db purchaseId + getLedgerTip :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) getLedgerTip db purchaseId = maybeFirstRow' Nothing toEntry $ @@ -251,9 +278,12 @@ getLedgerTip db purchaseId = db [sql| SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, - l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id, l.charge_id, + fp.purchase_key, tp.purchase_key FROM sx_badge_service_badge_ledger l LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + LEFT JOIN sx_badge_service_badge_purchases fp ON fp.badge_purchase_id = l.from_purchase_id + LEFT JOIN sx_badge_service_badge_purchases tp ON tp.badge_purchase_id = l.to_purchase_id WHERE l.badge_purchase_id = ? ORDER BY l.entry_id DESC LIMIT 1 @@ -276,37 +306,48 @@ getLedgerEntries db purchaseId afterEntryId = db [sql| SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, - l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id + l.entry_type, l.entry_credit_type, l.entry_debit_type, l.service_created_at, l.payment_id, pay.invoice_id, l.charge_id, + fp.purchase_key, tp.purchase_key FROM sx_badge_service_badge_ledger l LEFT JOIN sx_badge_service_payments pay ON pay.payment_id = l.payment_id + LEFT JOIN sx_badge_service_badge_purchases fp ON fp.badge_purchase_id = l.from_purchase_id + LEFT JOIN sx_badge_service_badge_purchases tp ON tp.badge_purchase_id = l.to_purchase_id WHERE l.badge_purchase_id = ? AND l.entry_id > ? ORDER BY l.entry_id |] (purchaseId, afterEntryId) -toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) :. (Maybe Text, Maybe Text) -> Maybe StatementEntry -toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) :. (paymentId_, invoiceId_)) = +toEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType, Text, Maybe Text, Maybe Text, UTCTime) :. (Maybe Text, Maybe Text, Maybe Text, Maybe C.PublicKeyEd25519, Maybe C.PublicKeyEd25519) -> Maybe StatementEntry +toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, entryType_, credit_, debit_, createdAt) :. (paymentId_, invoiceId_, chargeId_, fromKey_, toKey_)) = (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince = Nothing, createdAt, entryType}) - <$> entryTypeFromColumns ((InvoiceId <$> invoiceId_) <$ paymentId_) entryType_ credit_ debit_ + <$> entryTypeFromColumns references entryType_ credit_ debit_ + where + references = + EntryReferences + { refPayment = (InvoiceId <$> invoiceId_) <$ paymentId_, + refChargeId = chargeId_, + refFromPurchaseKey = fromKey_, + refToPurchaseKey = toKey_ + } +-- | The newest issuance, and only while it runs: after a tier change the superseded tier's month can end later. 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 + SELECT credential, period_end FROM sx_badge_service_badge_issuances + WHERE badge_purchase_id = ? + ORDER BY entry_id DESC, period_end DESC LIMIT 1 |] - (purchaseId, now) + (Only purchaseId) pure $ case rs of - [Only (Binary bs)] -> J.decodeStrict' bs + [(Binary bs, periodEnd)] | periodEnd > now -> J.decodeStrict' bs _ -> Nothing -- | creditPaymentId_ names the payment behind a payment credit among the rows. --- TODO write the other reference columns (charge_id, from_purchase_id, to_purchase_id) for entry types that carry one; only the tag is written today. appendLedgerPlan :: DB.Connection -> Int64 -> Maybe Text -> [StatementEntry] -> Maybe (StatementEntry, StatementEntry, BadgeCredential) -> IO () appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do mapM_ appendRow rows @@ -327,19 +368,31 @@ appendLedgerPlan db purchaseId creditPaymentId_ rows issuance_ = do where appendRow StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, createdAt, entryType} = do let (entryTypeT, creditType, debitType) = entryTypeColumns entryType - paymentId_ = case entryType of - SECredit SCPayment {} -> creditPaymentId_ - _ -> Nothing + (paymentId_, chargeId_, fromKey_, toKey_) = case entryType of + SECredit SCPayment {} -> (creditPaymentId_, Nothing, Nothing, Nothing) + SECredit SCCharge {chargeId} -> (Nothing, Just chargeId, Nothing, Nothing) + SECredit SCTransferIn {fromPurchaseKey} -> (Nothing, Nothing, Just fromPurchaseKey, Nothing) + SEDebit SDUpgrade {toPurchaseKey} -> (Nothing, Nothing, Nothing, Just toPurchaseKey) + SEDebit SDTransferOut {toPurchaseKey} -> (Nothing, Nothing, Nothing, Just toPurchaseKey) + _ -> (Nothing, Nothing, Nothing, Nothing) + fromPurchaseId_ <- mapM purchaseIdOf fromKey_ + toPurchaseId_ <- mapM purchaseIdOf toKey_ 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, payment_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + balance_badge_type, service_created_at, created_at, entry_type, entry_credit_type, entry_debit_type, payment_id, charge_id, + from_purchase_id, to_purchase_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType, paymentId_)) + ((entryId, purchaseId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs) :. (balanceBadgeType, createdAt, createdAt, entryTypeT, creditType, debitType, paymentId_, chargeId_) :. (fromPurchaseId_, toPurchaseId_)) insertedRowId db + -- a row whose reference is lost can never be read back, so the write fails and its transaction rolls back + purchaseIdOf :: C.PublicKeyEd25519 -> IO Int64 + purchaseIdOf key = + maybeFirstRow fromOnly (DB.query db "SELECT badge_purchase_id FROM sx_badge_service_badge_purchases WHERE purchase_key = ?" (Only key)) + >>= maybe (fail "badge ledger row names a purchase key with no purchase") pure -- The claim takes one use before adding the purchase, so a concurrent revoke or redemption waits on this row and sees the new count. -- Run it in the credential's transaction. @@ -366,16 +419,19 @@ createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey = Bad -- | In the transaction of the credential rows. The payment's unique (provider, provider_ref) is the -- claim: a transaction presented twice at once, or under another key, inserts nothing the second time. createStorePurchase :: DB.Connection -> NewStorePurchase -> UTCTime -> IO (Maybe Int64) -createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do +createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey = BadgeMasterKey mk, badgeType, subscriptionState, providerToken} now = do claimed <- executeChanging db [sql| - INSERT INTO sx_badge_service_payments (payment_id, provider, provider_ref, amount, currency, status, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?) + INSERT INTO sx_badge_service_payments + (payment_id, provider, provider_ref, amount, currency, status, subscription_state, subscription_checked_at, provider_token, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (provider, provider_ref) DO NOTHING |] - (paymentId, textEncode provider, providerRef, (\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, paymentStatusText PSSettled, now, now) + ( (paymentId, textEncode provider, providerRef, (\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, paymentStatusText PSSettled) + :. (textEncode <$> subscriptionState, now <$ subscriptionState, providerToken, now, now) + ) if claimed == 0 then pure Nothing else do @@ -389,6 +445,132 @@ createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) Just <$> insertedRowId db +-- | A payment's move from the purchase holding it to a new purchase under another key. fromRows are the old +-- purchase's rows that end its share, written after the new purchase exists, as they name its key. +data PaymentRebind = PaymentRebind + { paymentId :: Text, + fromPurchaseId :: Int64, + fromTipId :: Text, + fromRows :: [StatementEntry], + purchaseKey :: C.PublicKeyEd25519, + masterKey :: BadgeMasterKey, + badgeType :: BadgeType + } + +-- | In the transaction of the new purchase's own rows. Writes nothing and returns Nothing when another request +-- moved the payment or the old purchase's ledger since fromTipId was read. +rebindStorePayment :: DB.Connection -> PaymentRebind -> UTCTime -> IO (Maybe Int64) +rebindStorePayment db PaymentRebind {paymentId, fromPurchaseId, fromTipId, fromRows, purchaseKey, masterKey = BadgeMasterKey mk, badgeType} now = do + tip <- lockedLedgerTip db fromPurchaseId now + if fmap entryId tip /= Just fromTipId + then pure Nothing + else do + released <- + executeChanging + db + "UPDATE sx_badge_service_badge_purchases SET payment_id = NULL, updated_at = ? WHERE badge_purchase_id = ? AND payment_id = ?" + (now, fromPurchaseId, paymentId) + if released == 0 + then pure Nothing + else do + DB.execute + db + [sql| + INSERT INTO sx_badge_service_badge_purchases + (purchase_key, master_key, initial_badge_type, current_badge_type, status, payment_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (purchaseKey, Binary mk, badgeType, badgeType, PSIssued, paymentId, now, now) + purchaseId <- insertedRowId db + appendLedgerPlan db fromPurchaseId Nothing fromRows Nothing + DB.execute db "UPDATE sx_badge_service_payments SET rebound_at = ?, updated_at = ? WHERE payment_id = ?" (now, now, paymentId) + pure $ Just purchaseId + +-- | The latest credential expiry of the purchase the payment last moved from; Nothing if it never moved. +-- Only that purchase's transferOut names the holder, as a payment moves only to a key that held no purchase. +getMovedFromExpiry :: DB.Connection -> Text -> IO (Maybe UTCTime) +getMovedFromExpiry db paymentId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT i.expiry + FROM sx_badge_service_badge_purchases p + JOIN sx_badge_service_badge_ledger l ON l.to_purchase_id = p.badge_purchase_id + JOIN sx_badge_service_badge_issuances i ON i.badge_purchase_id = l.badge_purchase_id + WHERE p.payment_id = ? AND l.entry_type = 'debit' AND l.entry_debit_type = 'transferOut' + ORDER BY i.expiry DESC + LIMIT 1 + |] + (Only paymentId) + +-- | What a renewal asks the store about, for a purchase a subscription funds. +data PurchaseSubscription = PurchaseSubscription + { paymentId :: Text, + provider :: PaymentProvider, + subscriptionRef :: SubscriptionRef, + storedState :: SubscriptionState, + checkedAt :: Maybe UTCTime + } + +-- | Nothing for a purchase no subscription funds. +getPurchaseSubscription :: DB.Connection -> Int64 -> IO (Maybe PurchaseSubscription) +getPurchaseSubscription db purchaseId = + maybeFirstRow' Nothing toSubscription $ + DB.query + db + [sql| + SELECT pay.payment_id, pay.provider, pay.provider_ref, pay.provider_token, pay.subscription_state, pay.subscription_checked_at + FROM sx_badge_service_badge_purchases p + JOIN sx_badge_service_payments pay ON pay.payment_id = p.payment_id + WHERE p.badge_purchase_id = ? AND pay.subscription_state IS NOT NULL + |] + (Only purchaseId) + where + toSubscription (paymentId, provider_, providerRef, token_, state_, checkedAt) = do + provider <- textDecode provider_ + subscriptionRef <- case provider of + PPApple -> Just $ SubscriptionApple providerRef + PPGoogle -> SubscriptionGoogle <$> token_ + _ -> Nothing + storedState <- textDecode state_ + Just PurchaseSubscription {paymentId, provider, subscriptionRef, storedState, checkedAt} + +-- | Taken before the store is asked, so a check that fails or never returns still holds the next one off. +stampSubscriptionCheck :: DB.Connection -> Text -> UTCTime -> IO () +stampSubscriptionCheck db paymentId now = + DB.execute db "UPDATE sx_badge_service_payments SET subscription_checked_at = ?, updated_at = ? WHERE payment_id = ?" (now, now, paymentId) + +setSubscriptionState :: DB.Connection -> Text -> SubscriptionState -> UTCTime -> IO () +setSubscriptionState db paymentId state now = + DB.execute db "UPDATE sx_badge_service_payments SET subscription_state = ?, updated_at = ? WHERE payment_id = ?" (textEncode state, now, paymentId) + +-- | The id a charge was recorded under, and whether a ledger row grants it. +getSubscriptionCharge :: DB.Connection -> Text -> Text -> IO (Maybe (Text, Bool)) +getSubscriptionCharge db paymentId chargeRef = + maybeFirstRow (\(chargeId, granted) -> (chargeId, (granted :: Int) > 0)) $ + DB.query + db + [sql| + SELECT c.charge_id, CASE WHEN EXISTS (SELECT 1 FROM sx_badge_service_badge_ledger l WHERE l.charge_id = c.charge_id) THEN 1 ELSE 0 END + FROM sx_badge_service_subscription_charges c + WHERE c.payment_id = ? AND c.provider_charge_ref = ? + |] + (paymentId, chargeRef) + +-- | A charge the store reports again is recorded once, by its own reference. +insertSubscriptionCharge :: DB.Connection -> Text -> Text -> StoreCharge -> UTCTime -> IO () +insertSubscriptionCharge db paymentId chargeId StoreCharge {chargeRef, planId, periodStart, periodEnd, chargePaid = paid} now = + DB.execute + db + [sql| + INSERT INTO sx_badge_service_subscription_charges + (charge_id, payment_id, provider_charge_ref, plan_id, period_start, period_end, amount, currency, created_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT (payment_id, provider_charge_ref) DO NOTHING + |] + ((chargeId, paymentId, chargeRef, planId, periodStart, periodEnd) :. ((\(CurrencyAmount a) -> a) . fst <$> paid, snd <$> paid, now)) + -- | Revoked and AlreadyRevoked carry the code id, so the caller can retire the code's group tracker, -- or repair one an earlier revoke left live. data RevokeResult = Revoked Int64 | AlreadyRevoked Int64 | AlreadyRedeemed | NoSuchCode 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 92d3b5fd47..b2c3b86002 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/Postgres/Migrations.hs @@ -19,7 +19,8 @@ schemaMigrations :: [(String, Text, Maybe Text)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), - ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts), + ("20261012_badge_subscriptions", m20261012_badge_subscriptions, Just down_m20261012_badge_subscriptions) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -191,6 +192,86 @@ DROP INDEX @idx_payments_provider_ref; CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); |] +m20261012_badge_subscriptions :: Text +m20261012_badge_subscriptions = + withPrefix + servicePrefix + -- provider_token is a Play purchase token, a bearer secret: it is kept only for a subscription, which Play is asked about again. + [r| +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, + plan_id TEXT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + amount BIGINT, + currency TEXT, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +); + +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; + +CREATE UNIQUE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id, badge_purchase_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_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +ALTER TABLE @payments ADD COLUMN subscription_state TEXT; + +ALTER TABLE @payments ADD COLUMN subscription_checked_at TIMESTAMPTZ; + +ALTER TABLE @payments ADD COLUMN provider_token TEXT; + +ALTER TABLE @payments ADD COLUMN rebound_at TIMESTAMPTZ; + +ALTER TABLE @payments DROP COLUMN subscription_renews_at; + +ALTER TABLE @payments DROP COLUMN grace_until; + +ALTER TABLE @payments DROP COLUMN cancelled; +|] + +down_m20261012_badge_subscriptions :: Text +down_m20261012_badge_subscriptions = + withPrefix + servicePrefix + [r| +ALTER TABLE @payments ADD COLUMN subscription_renews_at TIMESTAMPTZ; + +ALTER TABLE @payments ADD COLUMN grace_until TIMESTAMPTZ; + +ALTER TABLE @payments ADD COLUMN cancelled SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE @payments DROP COLUMN rebound_at; + +ALTER TABLE @payments DROP COLUMN provider_token; + +ALTER TABLE @payments DROP COLUMN subscription_checked_at; + +ALTER TABLE @payments DROP COLUMN subscription_state; + +DROP INDEX @idx_badge_ledger_to_purchase; + +DROP INDEX @idx_badge_ledger_from_purchase; + +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; + +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; + +DROP INDEX @idx_badge_ledger_charge; + +ALTER TABLE @badge_ledger DROP COLUMN charge_id; + +DROP TABLE @subscription_charges; +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA; 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 cd3d97f0ac..7f8659d60d 100644 --- a/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs +++ b/apps/simplex-badge-service/src/BadgeService/Store/SQLite/Migrations.hs @@ -20,7 +20,8 @@ schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations = [ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema), ("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops), - ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts) + ("20260925_badge_store_receipts", m20260925_badge_store_receipts, Just down_m20260925_badge_store_receipts), + ("20261012_badge_subscriptions", m20261012_badge_subscriptions, Just down_m20261012_badge_subscriptions) ] -- | The client tables share this database, so the service tables are the same names behind a prefix. @@ -192,6 +193,86 @@ DROP INDEX @idx_payments_provider_ref; CREATE INDEX @idx_payments_provider_ref ON @payments(provider, provider_ref); |] +m20261012_badge_subscriptions :: Query +m20261012_badge_subscriptions = + withPrefix + servicePrefix + -- provider_token is a Play purchase token, a bearer secret: it is kept only for a subscription, which Play is asked about again. + [sql| +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, + plan_id TEXT NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + amount INTEGER, + currency TEXT, + created_at TEXT NOT NULL, + UNIQUE(payment_id, provider_charge_ref) +) STRICT; + +ALTER TABLE @badge_ledger ADD COLUMN charge_id TEXT REFERENCES @subscription_charges; + +CREATE UNIQUE INDEX @idx_badge_ledger_charge ON @badge_ledger(charge_id, badge_purchase_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_from_purchase ON @badge_ledger(from_purchase_id); + +CREATE INDEX @idx_badge_ledger_to_purchase ON @badge_ledger(to_purchase_id); + +ALTER TABLE @payments ADD COLUMN subscription_state TEXT; + +ALTER TABLE @payments ADD COLUMN subscription_checked_at TEXT; + +ALTER TABLE @payments ADD COLUMN provider_token TEXT; + +ALTER TABLE @payments ADD COLUMN rebound_at TEXT; + +ALTER TABLE @payments DROP COLUMN subscription_renews_at; + +ALTER TABLE @payments DROP COLUMN grace_until; + +ALTER TABLE @payments DROP COLUMN cancelled; +|] + +down_m20261012_badge_subscriptions :: Query +down_m20261012_badge_subscriptions = + withPrefix + servicePrefix + [sql| +ALTER TABLE @payments ADD COLUMN subscription_renews_at TEXT; + +ALTER TABLE @payments ADD COLUMN grace_until TEXT; + +ALTER TABLE @payments ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE @payments DROP COLUMN rebound_at; + +ALTER TABLE @payments DROP COLUMN provider_token; + +ALTER TABLE @payments DROP COLUMN subscription_checked_at; + +ALTER TABLE @payments DROP COLUMN subscription_state; + +DROP INDEX @idx_badge_ledger_to_purchase; + +DROP INDEX @idx_badge_ledger_from_purchase; + +ALTER TABLE @badge_ledger DROP COLUMN to_purchase_id; + +ALTER TABLE @badge_ledger DROP COLUMN from_purchase_id; + +DROP INDEX @idx_badge_ledger_charge; + +ALTER TABLE @badge_ledger DROP COLUMN charge_id; + +DROP TABLE @subscription_charges; +|] + {- TODO [badges] deferred with the draft in M20260915_user_badges, service only. ALTER TABLE @payments ADD COLUMN receipt_hash BLOB; diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs index 2789e80c54..064cc56ecb 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs @@ -2,23 +2,40 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} module BadgeService.StoreReceipts ( VerifiedStoreTransaction (..), StoreRefusal (..), StoreVerifier (..), + StoreChecks (..), StoreReceipt (..), + SubscriptionRef (..), + SubscriptionCheck (..), + StoreCharge (..), + StoreVerifierFailure (..), noStoreVerifier, toStoreReceipt, + checkStoreSubscription, + storeRequest, ) where -import Control.Exception (evaluate) +import BadgeService.Log (logInfo, logWarn) +import Control.Exception (Exception, catch, evaluate, try) +import qualified Data.ByteString.Lazy as LB import Data.Char (isAlphaNum, isAscii, isAsciiLower, isAsciiUpper, isControl, isDigit, isPrint, isSpace) +import Data.Functor (($>)) +import Data.Int (Int64) import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionId, googlePurchaseRef) +import Data.Time.Clock (UTCTime) +import Network.HTTP.Client (HttpException (..), Manager, Request, Response (..), brReadSome, parseRequest, withResponse) +import Network.HTTP.Types (Status (..)) +import Simplex.Chat.Badges.Service (SubscriptionState) +import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionRef, googlePurchaseRef) import Simplex.Chat.PaymentService.Types (CurrencyAmount, PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Messaging.Util (catchOwn', tshow) import System.Timeout (timeout) @@ -27,7 +44,7 @@ import System.Timeout (timeout) -- TODO [badges] PaymentFunding's PFApple and PFGoogle sketch the same thing and have no producer; -- delete them, or build them out and reconcile. data VerifiedStoreTransaction = VerifiedStoreTransaction - { transactionRef :: Text, -- from what was verified: Apple's transactionId, googlePurchaseRef of the token asked about + { transactionRef :: Text, -- from what was verified: applePayloadRef of Apple's payload, googlePurchaseRef of the token asked about productId :: Text, quantity :: Int, -- as the store reported it; the service refuses any but 1, as the apps never buy more -- costs the buyer nothing: Apple's Sandbox, which a public TestFlight build buys in, and Google's license testers @@ -49,34 +66,92 @@ data StoreRefusal | SRNotConfigured -- no verifier for this store is deployed; the purchase may be real deriving (Eq, Show) --- | Apple signs its receipt and ships the certificate chain in it, so it is verified with no network --- call; a Play token is opaque and must be asked about, which is why only the Play fields are in IO. +-- | What a refusal was, for a log. The reasons carry no receipt, token or key: a store request's +-- exceptions are reduced to their kind, and an unsendable token to its shape. +refusalLabel :: StoreRefusal -> Text +refusalLabel = \case + SRInvalid reason -> "invalid: " <> reason + SRTestPurchase -> "a test purchase" + SRPending -> "not settled yet" + SRUnreachable reason -> "no answer: " <> reason + SRVerifierFailed reason -> "this service failed: " <> reason + SRNotConfigured -> "no verifier configured" + +-- | Apple's receipt is verified offline, from the certificate chain it ships; a Play token is opaque +-- and must be asked about, and a subscription's charges are known only to its store, so those fields are in IO. data StoreVerifier = StoreVerifier { -- the JWS verifyApple :: Maybe (Text -> Either StoreRefusal VerifiedStoreTransaction), verifyGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction)), -- the product id and the token acknowledgeGoogle :: Maybe (Text -> Text -> IO (Either StoreRefusal ())), + acknowledgeGoogleSubscription :: Maybe (Text -> Text -> IO (Either StoreRefusal ())), + -- with the product the presented receipt names, absent on a renewal + checkSubscription :: Maybe (SubscriptionRef -> Maybe Text -> IO (Either StoreRefusal SubscriptionCheck)), -- microseconds; a lane answers one request at a time, so a verifier that does not finish holds up the rest of its lane - verifyTimeout :: Int + verifyTimeout :: Int, + storeChecks :: StoreChecks, + -- Local testing only: credits a purchase the store itself calls a test, which no real buyer pays for. + creditTestPurchases :: Bool } +-- | When a subscription's store is asked about it. A real store's next charge has not started while coverage +-- runs, and asking spends the quota every subscriber shares, so only a dev deployment checks at every request. +data StoreChecks = ChecksAfterCoverage | ChecksAtEveryRequest + deriving (Eq, Show) + -- | A store payment, named by the store's own reference before anything is verified. data StoreReceipt = StoreReceipt { txRef :: StoreTransactionRef, + receiptProductId :: Maybe Text, -- named by a Play receipt; Apple's is known only once its JWS is verified + subscriptionRef :: SubscriptionRef, verifyReceipt :: IO (Either StoreRefusal VerifiedStoreTransaction), - acknowledgeReceipt :: Maybe (VerifiedStoreTransaction -> IO (Either StoreRefusal ())) + acknowledgeReceipt :: Maybe (VerifiedStoreTransaction -> IO (Either StoreRefusal ())), + acknowledgeSubscription :: Maybe (IO (Either StoreRefusal ())) } +-- | What a store is asked about a subscription by: Apple's original transaction id, or Play's token. +-- The token is a bearer secret, so this has no Show. +data SubscriptionRef = SubscriptionApple Text | SubscriptionGoogle Text + +data SubscriptionCheck = SubscriptionCheck + { subscriptionState :: SubscriptionState, + testSubscription :: Bool, -- costs the buyer nothing, as a testPurchase does + latestCharge :: Maybe StoreCharge -- the latest paid period, if the store charged at all + } + deriving (Show) + +data StoreCharge = StoreCharge + { chargeRef :: Text, -- Apple's transactionId, a hash of Play's order id + planId :: Text, -- Apple's product id, Play's base plan + periodStart :: UTCTime, + periodEnd :: UTCTime, + chargePaid :: Maybe (CurrencyAmount, Text), -- in minor units; Play reports no price + revoked :: Bool, -- refunded or revoked + -- Apple: any offerType, so an introductory offer (free trial, pay as you go, pay up front), a promotional offer, + -- an offer code or a win-back offer; Play: a free-trial or introductory-price phase + offer :: Bool + } + deriving (Show) + noStoreVerifier :: StoreVerifier -noStoreVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Nothing, acknowledgeGoogle = Nothing, verifyTimeout = 10000000} +noStoreVerifier = StoreVerifier {verifyApple = Nothing, verifyGoogle = Nothing, acknowledgeGoogle = Nothing, acknowledgeGoogleSubscription = Nothing, checkSubscription = Nothing, verifyTimeout = 10000000, storeChecks = ChecksAfterCoverage, creditTestPurchases = False} -- | Nothing for a payment no store made. Exceptions are not logged, since they can quote the receipt -- or, from Google, a URL holding the token. toStoreReceipt :: StoreVerifier -> ServicePayment -> Maybe (Either StoreRefusal StoreReceipt) -toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, acknowledgeGoogle, verifyTimeout} = \case - SPApple {jws} -> Just $ case appleTransactionId jws of +toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, acknowledgeGoogle, acknowledgeGoogleSubscription, verifyTimeout} = \case + SPApple {jws} -> Just $ case appleTransactionRef jws of Nothing -> Left $ SRInvalid "names no transaction" - Just ref -> Right $ StoreReceipt (StoreTransactionRef PPApple ref) (maybe unconfigured (\verify -> offline $ verify jws) verifyApple) Nothing + Just ref -> + Right + StoreReceipt + { txRef = StoreTransactionRef PPApple ref, + receiptProductId = Nothing, + subscriptionRef = SubscriptionApple ref, + verifyReceipt = maybe unconfigured (\verify -> offline $ verify jws) verifyApple, + acknowledgeReceipt = Nothing, + acknowledgeSubscription = Nothing + } SPGoogle {productId, token} -- the claim is the token's hash, so neither string may name any purchase but the one it claims, -- whatever path a verifier builds from them @@ -84,24 +159,79 @@ toStoreReceipt StoreVerifier {verifyApple, verifyGoogle, acknowledgeGoogle, veri -- Play documents no token grammar, so this is our guess, and refusing to ask Play is not its verdict | not (googleToken token) -> Just $ Left $ SRUnreachable $ "a Play token this service will not send: " <> tokenShape token | otherwise -> - Just $ Right $ - StoreReceipt - (StoreTransactionRef PPGoogle (googlePurchaseRef token)) - (maybe unconfigured (\verify -> online "google verifier" $ verify productId token) verifyGoogle) - ((\acknowledge VerifiedStoreTransaction {alreadyAcknowledged} -> if alreadyAcknowledged then pure (Right ()) else online "acknowledging: google" $ acknowledge productId token) <$> acknowledgeGoogle) + Just $ + Right + StoreReceipt + { txRef = StoreTransactionRef PPGoogle (googlePurchaseRef token), + receiptProductId = Just productId, + subscriptionRef = SubscriptionGoogle token, + verifyReceipt = maybe unconfigured (\verify -> online "google verifier" $ verify productId token) verifyGoogle, + acknowledgeReceipt = (\acknowledge VerifiedStoreTransaction {alreadyAcknowledged} -> if alreadyAcknowledged then pure (Right ()) else online "acknowledging: google" $ acknowledge productId token) <$> acknowledgeGoogle, + acknowledgeSubscription = (\acknowledge -> online "acknowledging: google" $ acknowledge productId token) <$> acknowledgeGoogleSubscription + } SPInvoice {} -> Nothing SPReceipt {} -> Nothing where unconfigured = pure $ Left SRNotConfigured -- nothing was fetched, so a throw or an overrun is this service's failure or a shape it does not know, never an outage - offline verdict = - (fromMaybe (Left $ SRVerifierFailed "apple verifier timed out") <$> timeout verifyTimeout (forced verdict)) - `catchOwn'` \_ -> pure $ Left $ SRVerifierFailed "apple verifier threw" - online what call = - (fromMaybe (Left $ SRUnreachable $ what <> " timed out") <$> timeout verifyTimeout (call >>= forced)) - `catchOwn'` \_ -> pure $ Left $ SRUnreachable $ what <> " threw" - -- a verdict holding a thunk that throws would otherwise throw later, outside these handlers - forced = either (fmap Left . evaluate) (fmap Right . evaluate) + offline verdict = do + r <- + (fromMaybe (Left $ SRVerifierFailed "apple verifier timed out") <$> timeout verifyTimeout (forced verdict)) + `catchOwn'` \_ -> pure $ Left $ SRVerifierFailed "apple verifier threw" + logInfo $ "apple receipt: " <> either refusalLabel (const "vouched") r + pure r + online = askStore verifyTimeout + +-- | Both stores are asked online about a subscription, so a throw or an overrun is no verdict. A test +-- purchase is refused here, for a first purchase and a renewal alike. +checkStoreSubscription :: StoreVerifier -> SubscriptionRef -> Maybe Text -> IO (Either StoreRefusal SubscriptionCheck) +checkStoreSubscription StoreVerifier {checkSubscription, verifyTimeout, creditTestPurchases} ref product_ = case checkSubscription of + Nothing -> pure $ Left SRNotConfigured + Just check -> askStore verifyTimeout "subscription check" (check ref product_) >>= either (pure . Left) notTest + where + notTest checked@SubscriptionCheck {testSubscription} + | not testSubscription = pure $ Right checked + | creditTestPurchases = logWarn "[dev] a store test subscription is taken as paid: credit_test_purchases is on" $> Right checked + | otherwise = pure $ Left SRTestPurchase + +-- | Thrown by a verifier that cannot place what the store signed: its own failure, never the store's answer or silence. +newtype StoreVerifierFailure = StoreVerifierFailure Text + deriving (Show) + +instance Exception StoreVerifierFailure + +askStore :: Int -> Text -> IO (Either StoreRefusal a) -> IO (Either StoreRefusal a) +askStore limit what call = do + r <- + (fromMaybe (Left $ SRUnreachable $ what <> " timed out") <$> timeout limit ((call >>= forced) `catch` verifierFailed)) + `catchOwn'` \_ -> pure $ Left $ SRUnreachable $ what <> " threw" + -- that the store was asked and what came of it, since not every path answers with the refusal + logInfo $ what <> ": " <> either refusalLabel (const "vouched") r + pure r + where + verifierFailed (StoreVerifierFailure reason) = pure $ Left $ SRVerifierFailed reason + +-- | A request to a store: its status and a body of at most a mebibyte. http-client's exceptions show +-- the request, whose URL can hold a purchase token, so only their kind is kept. +storeRequest :: Manager -> Text -> (Request -> Request) -> Text -> IO (Either StoreRefusal (Int, LB.ByteString)) +storeRequest manager store prepare url = + try (parseRequest (T.unpack url) >>= \req -> withResponse (prepare req) manager readBody) >>= \case + Left (e :: HttpException) -> pure $ Left $ SRUnreachable $ "request to " <> store <> " failed: " <> failureKind e + Right (code, body) + | LB.length body > maxStoreBytes -> pure $ Left $ SRVerifierFailed $ store <> " answered HTTP " <> tshow code <> " with over " <> tshow maxStoreBytes <> " bytes" + | otherwise -> pure $ Right (code, body) + where + readBody resp = (statusCode (responseStatus resp),) <$> brReadSome (responseBody resp) (fromIntegral maxStoreBytes + 1) + failureKind = \case + HttpExceptionRequest _ content -> T.pack $ takeWhile (/= ' ') $ show content + InvalidUrlException _ _ -> "InvalidUrlException" + +maxStoreBytes :: Int64 +maxStoreBytes = 1024 * 1024 + +-- | A verdict holding a thunk that throws would otherwise throw later, outside the handlers. +forced :: Either StoreRefusal a -> IO (Either StoreRefusal a) +forced = either (fmap Left . evaluate) (fmap Right . evaluate) googleProductId :: Text -> Bool googleProductId pid = case T.uncons pid of diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs index c94c6dec61..1942a984e3 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs @@ -6,15 +6,18 @@ module BadgeService.StoreReceipts.Apple ( verifyAppleTransaction, + appleSubscriptionChecker, readAppleRoot, ) where -import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) -import Control.Exception (IOException, try) -import Control.Monad (guard, unless) +import BadgeService.Config (AppleServerApi (..)) +import BadgeService.StoreReceipts (StoreCharge (..), StoreRefusal (..), StoreVerifierFailure (..), SubscriptionCheck (..), VerifiedStoreTransaction (..), storeRequest) +import Control.Concurrent.STM (TVar, atomically, newTVarIO, readTVarIO, writeTVar) +import Control.Exception (IOException, evaluate, throwIO, try) +import Control.Monad (guard, unless, when) import Crypto.Hash.Algorithms (SHA256 (..)) -import Crypto.Number.Serialize (os2ip) +import Crypto.Number.Serialize (i2ospOf_, os2ip) import qualified Crypto.PubKey.ECC.ECDSA as ECDSA import qualified Crypto.PubKey.ECC.Types as ECC import qualified Data.Aeson as J @@ -24,31 +27,48 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.ByteString.Lazy as LB +import Data.Char (isDigit) import Data.Foldable (toList) +import Data.Functor (($>)) +import Data.Int (Int64) +import Data.List (find) import Data.List.NonEmpty (NonEmpty) import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds) import Data.Word (Word32) import Data.X509 import Data.X509.EC (unserializePoint) +import Data.X509.Memory (readKeyFileFromMemory) import Data.X509.Validation (SignatureFailure (..), SignatureVerification (..), verifySignedSignature) +import Network.HTTP.Client (Manager, Request (..)) +import Network.HTTP.Client.TLS (newTlsManager) +import Network.HTTP.Types (hAuthorization) +import Simplex.Chat.Badges.Service (SubscriptionState (..)) +import Simplex.Chat.PaymentService (applePayloadRef) import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) -import Simplex.Messaging.Util (eitherToMaybe) +import Simplex.Messaging.Util (catchOwn', eitherToMaybe, tshow) verifyAppleTransaction :: NonEmpty SignedCertificate -> Text -> Text -> Either StoreRefusal VerifiedStoreTransaction -verifyAppleTransaction roots ourBundleId jws = case T.splitOn "." jws of +verifyAppleTransaction roots ourBundleId jws = do + o <- verifiedPayload roots jws + tx <- either (const $ failed "the payload Apple signed is not a transaction") Right $ JT.parseEither appleTransactionP o + transaction ourBundleId tx + +-- | The payload of a JWS that a chain to a configured root signed. +verifiedPayload :: NonEmpty SignedCertificate -> Text -> Either StoreRefusal J.Object +verifiedPayload roots jws = case T.splitOn "." jws of [header, payload, signature] -> do leaf <- signingChain roots =<< certificateChain header sig <- jwsSignature signature key <- leafKey leaf unless (ECDSA.verify SHA256 key sig (encodeUtf8 $ header <> "." <> payload)) $ refused "the signature does not verify" - tx <- either (const $ failed "the payload Apple signed is not a transaction") Right $ do - o <- maybe (Left "not base64url JSON") Right $ jsonObject payload - JT.parseEither appleTransactionP o - transaction ourBundleId tx + maybe (failed "the payload Apple signed is not JSON") Right $ jsonObject payload _ -> refused "not three dot-separated parts" -- | A header or chain in a shape Apple may yet adopt proves no forgery, so it fails rather than refuses. @@ -105,7 +125,7 @@ jwsSignature s = case B64U.decodeUnpadded (encodeUtf8 s) of _ -> refused "the signature is not 64 base64url bytes" data AppleTransaction = AppleTransaction - { transactionId :: Text, + { transactionRef :: Text, productId :: Text, bundleId :: Text, environment :: Text, @@ -117,7 +137,8 @@ data AppleTransaction = AppleTransaction appleTransactionP :: J.Object -> JT.Parser AppleTransaction appleTransactionP o = do - transactionId <- o J..: "transactionId" + -- the same function names the claim, so the transaction vouched for is the one claimed + transactionRef <- maybe (fail "names no transaction") pure $ applePayloadRef o productId <- o J..: "productId" bundleId <- o J..: "bundleId" environment <- o J..: "environment" @@ -125,10 +146,10 @@ appleTransactionP o = do revoked <- isJust <$> (o J..:? "revocationDate" :: JT.Parser (Maybe J.Value)) price <- o J..:? "price" currency <- o J..:? "currency" - pure AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} + pure AppleTransaction {transactionRef, productId, bundleId, environment, quantity, revoked, price, currency} transaction :: Text -> AppleTransaction -> Either StoreRefusal VerifiedStoreTransaction -transaction ourBundleId AppleTransaction {transactionId, productId, bundleId, environment, quantity, revoked, price, currency} +transaction ourBundleId AppleTransaction {transactionRef, productId, bundleId, environment, quantity, revoked, price, currency} -- the configured bundle id may be the wrong one, and refusing for good would destroy every genuine purchase | bundleId /= ourBundleId = failed "another app's bundle id, or ours misconfigured" | revoked = refused "refunded or revoked" @@ -139,7 +160,7 @@ transaction ourBundleId AppleTransaction {transactionId, productId, bundleId, en _ -> failed "an environment this verifier does not know" Right VerifiedStoreTransaction - { transactionRef = transactionId, + { transactionRef, productId, quantity, testPurchase, @@ -187,3 +208,194 @@ readAppleRoot path = checkRoot root | signedBy root root = Right root | otherwise = Left $ path <> ": is not a self-signed root certificate" + +data AppleApi = AppleApi + { roots :: NonEmpty SignedCertificate, + ourBundleId :: Text, + serverApi :: AppleServerApi, + privateKey :: ECDSA.PrivateKey, + manager :: Manager, + apiToken :: TVar (Maybe (ByteString, UTCTime)) + } + +-- | Asks the App Store Server API about a subscription by its original transaction id. The key is read +-- here, at boot, as the Play service account is. +appleSubscriptionChecker :: NonEmpty SignedCertificate -> Text -> AppleServerApi -> IO (Either String (Text -> IO (Either StoreRefusal SubscriptionCheck))) +appleSubscriptionChecker roots ourBundleId serverApi@AppleServerApi {aPrivateKeyFile} = + readApiKey aPrivateKeyFile >>= \case + Left e -> pure $ Left e + Right privateKey -> do + manager <- newTlsManager + apiToken <- newTVarIO Nothing + pure $ Right $ subscriptionStatus AppleApi {roots, ourBundleId, serverApi, privateKey, manager, apiToken} + +readApiKey :: FilePath -> IO (Either String ECDSA.PrivateKey) +readApiKey path = + try (B.readFile path) >>= \case + Left (_ :: IOException) -> pure $ Left $ path <> ": could not be read" + Right pem -> pure $ case readKeyFileFromMemory pem of + [PrivKeyEC (PrivKeyEC_Named ECC.SEC_p256r1 d)] -> Right $ ECDSA.PrivateKey (ECC.getCurveByName ECC.SEC_p256r1) d + _ -> Left $ path <> ": is not one P-256 private key" + +subscriptionStatus :: AppleApi -> Text -> IO (Either StoreRefusal SubscriptionCheck) +subscriptionStatus api@AppleApi {serverApi = AppleServerApi {aApiHost}} originalId + | T.null originalId || not (T.all isDigit originalId) = pure $ failed "not an App Store transaction id" + | otherwise = + apiCall api (aApiHost <> "/inApps/v1/subscriptions/" <> originalId) >>= \case + Left refusal -> pure $ Left refusal + Right body -> checked $ statusVerdict api originalId body + where + -- a throw while checking what Apple signed is this verifier's failure, so it leaves as one + checked verdict = + (evaluate verdict >>= either (fmap Left . evaluate) (fmap Right . evaluate)) + `catchOwn'` \_ -> throwIO $ StoreVerifierFailure "apple verifier: threw checking a subscription status" + +apiCall :: AppleApi -> Text -> IO (Either StoreRefusal LB.ByteString) +apiCall api@AppleApi {manager, apiToken} url = do + bearer <- bearerToken api + storeRequest manager "Apple" (\req -> req {requestHeaders = [(hAuthorization, "Bearer " <> bearer)]}) url >>= \case + Left refusal -> pure $ Left refusal + Right (code, body) | code >= 200 && code < 300 -> pure $ Right body + Right (401, _) -> atomically (writeTVar apiToken Nothing) $> failed "Apple refused this service's API token" + Right (403, _) -> pure $ failed "Apple refused this service's API key" + -- a 404 among them, which Apple answers for a transaction id its environment does not hold + Right (code, _) -> pure $ Left $ SRUnreachable $ "Apple answered HTTP " <> tshow code + +-- | Signed here, so reused until shortly before it expires rather than signed for every call. +bearerToken :: AppleApi -> IO ByteString +bearerToken api@AppleApi {apiToken} = do + now <- getCurrentTime + readTVarIO apiToken >>= \case + Just (bearer, validUntil) | now < validUntil -> pure bearer + _ -> do + bearer <- signedApiToken api now + atomically $ writeTVar apiToken $ Just (bearer, addUTCTime (fromIntegral (apiTokenSeconds - 300)) now) + pure bearer + +apiTokenSeconds :: Int64 +apiTokenSeconds = 1200 + +signedApiToken :: AppleApi -> UTCTime -> IO ByteString +signedApiToken AppleApi {privateKey, ourBundleId, serverApi = AppleServerApi {aKeyId, aIssuerId}} now = do + ECDSA.Signature r s <- ECDSA.sign privateKey SHA256 signingInput + pure $ signingInput <> "." <> B64U.encodeUnpadded (i2ospOf_ 32 r <> i2ospOf_ 32 s) + where + issuedAt = floor (utcTimeToPOSIXSeconds now) :: Int64 + signingInput = segment (J.object ["alg" J..= ("ES256" :: Text), "kid" J..= aKeyId, "typ" J..= ("JWT" :: Text)]) <> "." <> segment claims + claims = + J.object + [ "iss" J..= aIssuerId, + "iat" J..= issuedAt, + "exp" J..= (issuedAt + apiTokenSeconds), + "aud" J..= ("appstoreconnect-v1" :: Text), + "bid" J..= ourBundleId + ] + segment = B64U.encodeUnpadded . LB.toStrict . J.encode + +-- | Everything Apple signed in the answer is checked against the configured roots like a receipt, but a +-- body that fails it is this service's failure: Apple answered, and no client presented it. +statusVerdict :: AppleApi -> Text -> LB.ByteString -> Either StoreRefusal SubscriptionCheck +statusVerdict AppleApi {roots, ourBundleId} originalId body = do + StatusResponse items <- either (const $ failed "could not read Apple's subscription status") Right $ J.eitherDecode' body + -- an omission is no more a verdict than a 404, and refusing it would end a purchase the buyer paid for + item <- maybe (Left $ SRUnreachable "Apple's answer names no such subscription") Right $ find ((== Just originalId) . itemOriginalId) items + LastTransaction {status, signedTransactionInfo, signedRenewalInfo} <- + either (const $ failed "could not read Apple's subscription status") Right $ JT.parseEither J.parseJSON item + SubscriptionTransaction {transactionId, txOriginalId, productId, bundleId, environment, purchaseDate, expiresDate, revoked, offer, familyShared, price, currency} <- + verifiedItem signedTransactionInfo subscriptionTransactionP + RenewalInfo {renewalOriginalId, autoRenewStatus} <- verifiedItem signedRenewalInfo renewalInfoP + when (bundleId /= ourBundleId) $ failed "another app's bundle id, or ours misconfigured" + when (txOriginalId /= originalId || renewalOriginalId /= originalId) $ failed "Apple answered about another subscription" + when familyShared $ refused "family-shared" + testSubscription <- case environment of + "Production" -> Right False + "Sandbox" -> Right True + _ -> failed "an environment this verifier does not know" + subscriptionState <- case (status, autoRenewStatus) of + (1, 1) -> Right SSRenewing + (1, 0) -> Right SSCancelled + (3, _) -> Right SSBillingRetry + (4, _) -> Right SSBillingRetry + (2, _) -> Right SSEnded + (5, _) -> Right SSEnded + _ -> failed $ "a status " <> tshow status <> " with autoRenewStatus " <> tshow autoRenewStatus <> " this verifier does not know" + pure + SubscriptionCheck + { subscriptionState, + testSubscription, + latestCharge = + Just + StoreCharge + { chargeRef = transactionId, + planId = productId, + periodStart = fromMillis purchaseDate, + periodEnd = fromMillis expiresDate, + chargePaid = paidOf price currency, + revoked, + offer + } + } + where + verifiedItem jws p = do + o <- either (Left . asVerifierFailure) Right $ verifiedPayload roots jws + either (const $ failed "the payload Apple signed is not a subscription transaction or renewal info") Right $ JT.parseEither p o + asVerifierFailure = \case + SRInvalid reason -> SRVerifierFailed $ "apple verifier: " <> reason + refusal -> refusal + fromMillis ms = posixSecondsToUTCTime $ fromIntegral ms / 1000 + +newtype StatusResponse = StatusResponse [J.Value] + +instance J.FromJSON StatusResponse where + parseJSON = J.withObject "StatusResponse" $ \o -> do + groups :: [J.Value] <- o J..: "data" + StatusResponse . concat <$> mapM (J.withObject "SubscriptionGroupIdentifierItem" (J..: "lastTransactions")) groups + +itemOriginalId :: J.Value -> Maybe Text +itemOriginalId = JT.parseMaybe $ J.withObject "LastTransactionsItem" (J..: "originalTransactionId") + +data LastTransaction = LastTransaction + { status :: Int, + signedTransactionInfo :: Text, + signedRenewalInfo :: Text + } + +instance J.FromJSON LastTransaction where + parseJSON = J.withObject "LastTransactionsItem" $ \o -> + LastTransaction <$> o J..: "status" <*> o J..: "signedTransactionInfo" <*> o J..: "signedRenewalInfo" + +data SubscriptionTransaction = SubscriptionTransaction + { transactionId :: Text, + txOriginalId :: Text, + productId :: Text, + bundleId :: Text, + environment :: Text, + purchaseDate :: Int64, + expiresDate :: Int64, + revoked :: Bool, + offer :: Bool, -- any offerType: a free trial, an introductory or promotional price, an offer code + familyShared :: Bool, + price :: Maybe Integer, + currency :: Maybe Text + } + +subscriptionTransactionP :: J.Object -> JT.Parser SubscriptionTransaction +subscriptionTransactionP o = do + transactionId <- o J..: "transactionId" + txOriginalId <- o J..: "originalTransactionId" + productId <- o J..: "productId" + bundleId <- o J..: "bundleId" + environment <- o J..: "environment" + purchaseDate <- o J..: "purchaseDate" + expiresDate <- o J..: "expiresDate" + revoked <- isJust <$> (o J..:? "revocationDate" :: JT.Parser (Maybe J.Value)) + offer <- isJust <$> (o J..:? "offerType" :: JT.Parser (Maybe J.Value)) + familyShared <- (== Just ("FAMILY_SHARED" :: Text)) <$> o J..:? "inAppOwnershipType" + price <- o J..:? "price" + currency <- o J..:? "currency" + pure SubscriptionTransaction {transactionId, txOriginalId, productId, bundleId, environment, purchaseDate, expiresDate, revoked, offer, familyShared, price, currency} + +data RenewalInfo = RenewalInfo {renewalOriginalId :: Text, autoRenewStatus :: Int} + +renewalInfoP :: J.Object -> JT.Parser RenewalInfo +renewalInfoP o = RenewalInfo <$> o J..: "originalTransactionId" <*> o J..: "autoRenewStatus" diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs index 98cb1fc050..19699c9da8 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs @@ -5,16 +5,18 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} -module BadgeService.StoreReceipts.Google (playStoreVerifier) where +module BadgeService.StoreReceipts.Google (PlayVerifier (..), playStoreVerifier) where import BadgeService.Config (PlayStoreConfig (..)) -import BadgeService.StoreReceipts (StoreRefusal (..), VerifiedStoreTransaction (..)) +import BadgeService.StoreReceipts (StoreCharge (..), StoreRefusal (..), SubscriptionCheck (..), VerifiedStoreTransaction (..), storeRequest) import Control.Concurrent.STM import Control.Exception (IOException, try) +import Control.Monad (forM) import Crypto.Hash.Algorithms (SHA256 (..)) import qualified Crypto.PubKey.RSA as RSA import qualified Crypto.PubKey.RSA.PKCS15 as RSA import qualified Data.Aeson as J +import qualified Data.Aeson.KeyMap as KM import Data.Bifunctor (bimap) import Data.ByteString (ByteString) import qualified Data.ByteString as B @@ -22,7 +24,7 @@ import qualified Data.ByteString.Base64.URL as B64U import qualified Data.ByteString.Lazy as LB import Data.Functor (($>)) import Data.Int (Int64) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -30,18 +32,12 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime) import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) import Data.X509 (PrivKey (..)) import Data.X509.Memory (readKeyFileFromMemory) -import Network.HTTP.Client - ( HttpException (..), - Manager, - Request (..), - RequestBody (..), - Response (..), - brReadSome, - parseRequest, - withResponse, - ) +import Network.HTTP.Client (Manager, Request (..), RequestBody (..)) import Network.HTTP.Client.TLS (newTlsManager) -import Network.HTTP.Types (Status (..), hAuthorization, hContentType, methodPost, renderSimpleQuery) +import Network.HTTP.Types (hAuthorization, hContentType, methodPost, renderSimpleQuery) +import Simplex.Chat.Badges.Ledger (addMonths) +import Simplex.Chat.Badges.Service (SubscriptionState (..)) +import Simplex.Chat.Badges.StorePlan (playBasePlan) import Simplex.Chat.PaymentService (googlePurchaseRef) import Simplex.Messaging.Util (tshow) @@ -54,7 +50,15 @@ data PlayEnv = PlayEnv accessToken :: TVar (Maybe (ByteString, UTCTime)) } -playStoreVerifier :: PlayStoreConfig -> IO (Either String (Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction), Text -> Text -> IO (Either StoreRefusal ()))) +-- | Each call takes the product id and the token, but the subscription check: subscriptionsv2 reads by token alone. +data PlayVerifier = PlayVerifier + { verifyPlayPurchase :: Text -> Text -> IO (Either StoreRefusal VerifiedStoreTransaction), + acknowledgePlayPurchase :: Text -> Text -> IO (Either StoreRefusal ()), + checkPlaySubscription :: Text -> IO (Either StoreRefusal SubscriptionCheck), + acknowledgePlaySubscription :: Text -> Text -> IO (Either StoreRefusal ()) + } + +playStoreVerifier :: PlayStoreConfig -> IO (Either String PlayVerifier) playStoreVerifier config@PlayStoreConfig {gServiceAccountFile} = readServiceAccount gServiceAccountFile >>= \case Left e -> pure $ Left e @@ -62,7 +66,14 @@ playStoreVerifier config@PlayStoreConfig {gServiceAccountFile} = manager <- newTlsManager accessToken <- newTVarIO Nothing let env = PlayEnv {config, account, manager, accessToken} - pure $ Right (verifyPurchase env, acknowledgePurchase env) + pure $ + Right + PlayVerifier + { verifyPlayPurchase = verifyPurchase env, + acknowledgePlayPurchase = acknowledgePurchase env, + checkPlaySubscription = checkSubscription env, + acknowledgePlaySubscription = acknowledgeSubscription env + } readServiceAccount :: FilePath -> IO (Either String ServiceAccount) readServiceAccount path = @@ -87,11 +98,20 @@ verifyPurchase env productId token = (>>= purchaseVerdict productId token) <$> r acknowledgePurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ()) acknowledgePurchase env productId token = bimap acknowledging (const ()) <$> playCall env (\req -> req {method = methodPost}) (purchaseUrl env productId token <> ":acknowledge") - where - acknowledging = \case - SRUnreachable reason -> SRUnreachable $ "acknowledging: " <> reason - SRVerifierFailed reason -> SRVerifierFailed $ "acknowledging: " <> reason - refusal -> refusal + +-- | As a one-time purchase is: once, before it is credited, so Play's three-day refund never hits a credited one. +acknowledgeSubscription :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ()) +acknowledgeSubscription env productId token = + readSubscription env token >>= \case + Left refusal -> pure $ Left $ acknowledging refusal + Right SubscriptionPurchase {subscriptionAcknowledged = True} -> pure $ Right () + Right _ -> bimap acknowledging (const ()) <$> playCall env (\req -> req {method = methodPost}) (playUrl env ["purchases/subscriptions", productId, "tokens", token <> ":acknowledge"]) + +acknowledging :: StoreRefusal -> StoreRefusal +acknowledging = \case + SRUnreachable reason -> SRUnreachable $ "acknowledging: " <> reason + SRVerifierFailed reason -> SRVerifierFailed $ "acknowledging: " <> reason + refusal -> refusal readPurchase :: PlayEnv -> Text -> Text -> IO (Either StoreRefusal ProductPurchase) readPurchase env productId token = (>>= decoded) <$> playCall env id (purchaseUrl env productId token) @@ -99,21 +119,117 @@ readPurchase env productId token = (>>= decoded) <$> playCall env id (purchaseUr decoded = either (const $ Left $ SRVerifierFailed "could not read Play's purchase record") Right . J.eitherDecode' purchaseUrl :: PlayEnv -> Text -> Text -> Text -purchaseUrl PlayEnv {config = PlayStoreConfig {gApiHost, gPackageName}} productId token = - T.intercalate "/" [gApiHost, "androidpublisher/v3/applications", gPackageName, "purchases/products", productId, "tokens", token] +purchaseUrl env productId token = playUrl env ["purchases/products", productId, "tokens", token] + +playUrl :: PlayEnv -> [Text] -> Text +playUrl PlayEnv {config = PlayStoreConfig {gApiHost, gPackageName}} path = + T.intercalate "/" $ [gApiHost, "androidpublisher/v3/applications", gPackageName] <> path + +checkSubscription :: PlayEnv -> Text -> IO (Either StoreRefusal SubscriptionCheck) +checkSubscription env token = + playResponse env id (subscriptionUrl env token) >>= \case + -- Play answers 410 for a subscription that expired too long ago to be read + Right (410, _) -> pure $ Right SubscriptionCheck {subscriptionState = SSEnded, testSubscription = False, latestCharge = Nothing} + response -> (>>= subscriptionVerdict) . (>>= decodedSubscription) <$> either (pure . Left) (playAnswer env) response + +readSubscription :: PlayEnv -> Text -> IO (Either StoreRefusal SubscriptionPurchase) +readSubscription env token = (>>= decodedSubscription) <$> playCall env id (subscriptionUrl env token) + +subscriptionUrl :: PlayEnv -> Text -> Text +subscriptionUrl env token = playUrl env ["purchases/subscriptionsv2/tokens", token] + +decodedSubscription :: LB.ByteString -> Either StoreRefusal SubscriptionPurchase +decodedSubscription = either (const $ Left $ SRVerifierFailed "could not read Play's subscription record") Right . J.eitherDecode' + +-- | Play reports no current period start, so it is the period end less the plan's length; a plan this +-- service does not sell has no length, and its charge, which is never granted, is given no period. +-- The service grants a Play charge by its base plan alone, so a base plan it sells under another product is refused. +-- Revoking an order shows only as the subscription ending, so a Play charge is never revoked. +subscriptionVerdict :: SubscriptionPurchase -> Either StoreRefusal SubscriptionCheck +subscriptionVerdict SubscriptionPurchase {subscriptionStateName, testSubscription, lineItems} + | subscriptionStateName == "SUBSCRIPTION_STATE_PENDING" = Left SRPending + | otherwise = do + subscriptionState <- case subscriptionStateName of + "SUBSCRIPTION_STATE_ACTIVE" -> renewalState <$> onlyLineItem + "SUBSCRIPTION_STATE_PAUSED" -> renewalState <$> onlyLineItem + "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> Right SSBillingRetry + "SUBSCRIPTION_STATE_ON_HOLD" -> Right SSBillingRetry + "SUBSCRIPTION_STATE_CANCELED" -> Right SSCancelled + "SUBSCRIPTION_STATE_EXPIRED" -> Right SSEnded + "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED" -> Right SSEnded + _ -> Left $ SRVerifierFailed "a subscriptionState this service does not know" + latestCharge <- case lineItems of + [] | subscriptionState == SSEnded -> Right Nothing + _ -> onlyLineItem >>= lineItemCharge + pure SubscriptionCheck {subscriptionState, testSubscription, latestCharge} + where + onlyLineItem = case lineItems of + [item] -> Right item + _ -> Left $ SRVerifierFailed "Play answered a subscription without exactly one line item" + renewalState LineItem {autoRenewEnabled} = if autoRenewEnabled then SSRenewing else SSCancelled + lineItemCharge LineItem {expiryTime, itemProductId, basePlanId, latestSuccessfulOrderId, inOffer} = + forM latestSuccessfulOrderId $ \orderId -> case (expiryTime, itemProductId, basePlanId) of + (Just periodEnd, Just productId, Just planId) -> case playBasePlan planId of + Just (planProductId, _) | planProductId /= productId -> Left $ SRVerifierFailed "Play answered a base plan of another product" + plan_ -> + let periodStart = maybe periodEnd (\(_, (_, months)) -> addMonths (negate $ toInteger months) periodEnd) plan_ + -- hashed so the row does not show the order id; unkeyed, so a holder of the buyer's receipt can still find it + in Right StoreCharge {chargeRef = googlePurchaseRef orderId, planId, periodStart, periodEnd, chargePaid = Nothing, revoked = False, offer = inOffer} + _ -> Left $ SRVerifierFailed "Play answered a charge without its expiryTime, product or base plan" + +data SubscriptionPurchase = SubscriptionPurchase + { subscriptionStateName :: Text, + subscriptionAcknowledged :: Bool, + testSubscription :: Bool, + lineItems :: [LineItem] + } + +instance J.FromJSON SubscriptionPurchase where + parseJSON = J.withObject "SubscriptionPurchaseV2" $ \o -> do + subscriptionStateName <- o J..: "subscriptionState" + subscriptionAcknowledged <- (== Just ("ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED" :: Text)) <$> o J..:? "acknowledgementState" + testPurchase :: Maybe J.Value <- o J..:? "testPurchase" + let testSubscription = isJust testPurchase + lineItems <- o J..:? "lineItems" J..!= [] + pure SubscriptionPurchase {subscriptionStateName, subscriptionAcknowledged, testSubscription, lineItems} + +data LineItem = LineItem + { expiryTime :: Maybe UTCTime, + autoRenewEnabled :: Bool, + itemProductId :: Maybe Text, + basePlanId :: Maybe Text, + latestSuccessfulOrderId :: Maybe Text, + -- from the current phase: offerDetails names the offer it was bought with for its whole life + inOffer :: Bool + } + +instance J.FromJSON LineItem where + parseJSON = J.withObject "SubscriptionPurchaseLineItem" $ \o -> do + expiryTime <- o J..:? "expiryTime" + autoRenewEnabled <- maybe (pure False) (\p -> p J..:? "autoRenewEnabled" J..!= False) =<< o J..:? "autoRenewingPlan" + itemProductId <- o J..:? "productId" + basePlanId <- maybe (pure Nothing) (J..:? "basePlanId") =<< o J..:? "offerDetails" + latestSuccessfulOrderId <- o J..:? "latestSuccessfulOrderId" + phase :: Maybe J.Object <- o J..:? "offerPhase" + let inOffer = maybe False (\p -> KM.member "freeTrial" p || KM.member "introductoryPrice" p) phase + pure LineItem {expiryTime, autoRenewEnabled, itemProductId, basePlanId, latestSuccessfulOrderId, inOffer} playCall :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal LB.ByteString) -playCall env@PlayEnv {accessToken} prepare url = +playCall env prepare url = playResponse env prepare url >>= either (pure . Left) (playAnswer env) + +playResponse :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal (Int, LB.ByteString)) +playResponse env prepare url = bearerToken env >>= \case Left refusal -> pure $ Left refusal - Right bearer -> - playRequest env (\req -> (prepare req) {requestHeaders = [(hAuthorization, "Bearer " <> bearer)]}) url >>= \case - Left refusal -> pure $ Left refusal - Right (code, body) | code >= 200 && code < 300 -> pure $ Right body - Right (401, _) -> atomically (writeTVar accessToken Nothing) $> Left (SRVerifierFailed "Play refused this service's access token") - Right (403, _) -> pure $ Left $ SRVerifierFailed "the service account lacks the Play Console permission for this call" - -- a 404 among them, which Play may answer for a token it issued but has not yet recorded - Right (code, _) -> pure $ Left $ SRUnreachable $ "Play answered HTTP " <> tshow code + Right bearer -> playRequest env (\req -> (prepare req) {requestHeaders = [(hAuthorization, "Bearer " <> bearer)]}) url + +playAnswer :: PlayEnv -> (Int, LB.ByteString) -> IO (Either StoreRefusal LB.ByteString) +playAnswer PlayEnv {accessToken} = \case + (code, body) | code >= 200 && code < 300 -> pure $ Right body + (401, _) -> atomically (writeTVar accessToken Nothing) $> Left (SRVerifierFailed "Play refused this service's access token") + (403, _) -> pure $ Left $ SRVerifierFailed "the service account lacks the Play Console permission for this call" + -- a 404 among them, which Play may answer for a token it issued but has not yet recorded + (code, _) -> pure $ Left $ SRUnreachable $ "Play answered HTTP " <> tshow code purchaseVerdict :: Text -> Text -> ProductPurchase -> Either StoreRefusal VerifiedStoreTransaction purchaseVerdict productId token ProductPurchase {purchaseState, purchaseType, purchaseQuantity, purchaseProductId, alreadyAcknowledged} @@ -215,18 +331,4 @@ signedAssertion ServiceAccount {clientEmail, privateKey} audience now = segment = B64U.encodeUnpadded . LB.toStrict . J.encode playRequest :: PlayEnv -> (Request -> Request) -> Text -> IO (Either StoreRefusal (Int, LB.ByteString)) -playRequest PlayEnv {manager} prepare url = - try (parseRequest (T.unpack url) >>= \req -> withResponse (prepare req) manager readBody) >>= \case - -- http-client's exceptions show the request, whose URL can hold the purchase token, so only the kind is kept - Left (e :: HttpException) -> pure $ Left $ SRUnreachable $ "request to Google failed: " <> failureKind e - Right (code, body) - | LB.length body > maxPlayBytes -> pure $ Left $ SRVerifierFailed $ "Google answered HTTP " <> tshow code <> " with over " <> tshow maxPlayBytes <> " bytes" - | otherwise -> pure $ Right (code, body) - where - readBody resp = (statusCode (responseStatus resp),) <$> brReadSome (responseBody resp) (fromIntegral maxPlayBytes + 1) - failureKind = \case - HttpExceptionRequest _ content -> T.pack $ takeWhile (/= ' ') $ show content - InvalidUrlException _ _ -> "InvalidUrlException" - -maxPlayBytes :: Int64 -maxPlayBytes = 1024 * 1024 +playRequest PlayEnv {manager} = storeRequest manager "Google" diff --git a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs index f7b1a14c9b..823af288ea 100644 --- a/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs +++ b/apps/simplex-badge-service/src/BadgeService/StoreReceipts/Mock.hs @@ -1,22 +1,68 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -module BadgeService.StoreReceipts.Mock (mockStoreVerifier) where +module BadgeService.StoreReceipts.Mock (newMockStoreVerifier) where +import BadgeService.Catalog (playMonthlyPlan) import BadgeService.StoreReceipts +import Control.Applicative ((<|>)) +import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO) +import Control.Monad (guard) import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as JM import qualified Data.ByteString.Base64.URL as B64U +import qualified Data.Map.Strict as M import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Simplex.Chat.PaymentService (appleTransactionId, googlePurchaseRef) -import Simplex.Messaging.Util (eitherToMaybe) +import Data.Time.Clock (UTCTime, addUTCTime) +import Simplex.Chat.Badges.Ledger (addMonths) +import Simplex.Chat.Badges.Service (SubscriptionState (..)) +import Simplex.Chat.Badges.StorePlan (playBasePlan, storePlan) +import Simplex.Chat.PaymentService (appleTransactionRef, googlePurchaseRef) +import Simplex.Chat.PaymentService.Types (PaymentProvider (..)) +import Simplex.Messaging.Util (eitherToMaybe, tshow) -- | Vouches for any well-formed receipt without verifying anything. Each transaction is named by --- the same function its claim is named by, so the two cannot differ. -mockStoreVerifier :: StoreVerifier -mockStoreVerifier = noStoreVerifier {verifyApple = Just mockApple, verifyGoogle = Just mockGoogle} +-- the same function its claim is named by, so the two cannot differ. A subscription is charged for +-- the plan its first purchase or a tier raise names, and that plan is kept in memory to charge each renewal again. +newMockStoreVerifier :: IO UTCTime -> IO StoreVerifier +newMockStoreVerifier clock = do + plans <- newTVarIO M.empty + pure noStoreVerifier {verifyApple = Just mockApple, verifyGoogle = Just mockGoogle, checkSubscription = Just $ mockSubscription clock plans} + +-- | Keyed by Apple's original transaction id or by Play's token, which only this process holds. +mockSubscription :: IO UTCTime -> TVar (M.Map (Either Text Text) Text) -> SubscriptionRef -> Maybe Text -> IO (Either StoreRefusal SubscriptionCheck) +mockSubscription clock plans ref = \case + Just productId -> case firstPlan productId of + Nothing -> pure $ Left $ SRUnreachable "the mock knows no plan for this subscription" + Just planId -> do + atomically $ modifyTVar' plans $ M.insert key planId + renewing planId + Nothing -> + M.lookup key <$> readTVarIO plans >>= \case + Nothing -> pure $ Right SubscriptionCheck {subscriptionState = SSEnded, testSubscription = False, latestCharge = Nothing} + Just planId -> renewing planId + where + (provider, key) = case ref of + SubscriptionApple originalId -> (PPApple, Left originalId) + SubscriptionGoogle token -> (PPGoogle, Right token) + firstPlan productId = case ref of + SubscriptionApple _ -> productId <$ storePlan PPApple productId + SubscriptionGoogle token -> testTokenPlan productId token <|> playMonthlyPlan productId + -- a test token names its base plan, test.., as the apps' test products sign it + testTokenPlan productId token = do + (basePlanId, rest) <- T.breakOn "." <$> T.stripPrefix "test." token + guard $ T.length rest > 1 + (planProductId, _) <- playBasePlan basePlanId + basePlanId <$ guard (planProductId == productId) + renewing planId = do + now <- clock + let periodStart = addUTCTime (-60) now + months = maybe 0 snd $ storePlan provider planId + charge = StoreCharge {chargeRef = "mock-" <> tshow periodStart, planId, periodStart, periodEnd = addMonths (fromIntegral months) periodStart, chargePaid = Nothing, revoked = False, offer = False} + pure $ Right SubscriptionCheck {subscriptionState = SSRenewing, testSubscription = False, latestCharge = Just charge} mockApple :: Text -> Either StoreRefusal VerifiedStoreTransaction mockApple signed = case T.splitOn "." signed of @@ -27,7 +73,7 @@ mockApple signed = case T.splitOn "." signed of productId <- case JM.lookup "productId" o of Just (J.String p) -> Right p _ -> Left $ SRInvalid "no productId" - transactionRef <- maybe (Left $ SRInvalid "no transactionId") Right $ appleTransactionId signed + transactionRef <- maybe (Left $ SRInvalid "no transactionId") Right $ appleTransactionRef signed Right $ vouched transactionRef productId _ -> Left $ SRInvalid "not three dot-separated parts" diff --git a/apps/simplex-badge-service/src/BadgeService/Web/Server.hs b/apps/simplex-badge-service/src/BadgeService/Web/Server.hs index 0c742bccf1..f3eb512dc0 100644 --- a/apps/simplex-badge-service/src/BadgeService/Web/Server.hs +++ b/apps/simplex-badge-service/src/BadgeService/Web/Server.hs @@ -6,6 +6,8 @@ module BadgeService.Web.Server ( WebEnv (..), Limit (..), + Bucket (..), + takeFrom, newWebEnv, exportWebapp, takeToken, @@ -130,6 +132,13 @@ reclaim now buckets fresh :: UTCTime -> Bucket -> Bool fresh now Bucket {bkStarted} = diffUTCTime now bkStarted < limitWindow +-- | A request against a minute's limit: the bucket after it, or how long until the minute ends. +takeFrom :: Int -> UTCTime -> Maybe Bucket -> Either NominalDiffTime Bucket +takeFrom limit now = \case + Just b@Bucket {bkCount, bkStarted} + | fresh now b -> if bkCount < limit then Right b {bkCount = bkCount + 1} else Left (limitWindow - diffUTCTime now bkStarted) + _ -> Right Bucket {bkCount = 1, bkStarted = now} + takeToken :: WebEnv -> Limit -> Text -> IO (Maybe Int) takeToken WebEnv {weBuckets} Limit {lmName, lmPerMinute} client = do now <- getCurrentTime @@ -139,16 +148,7 @@ takeToken WebEnv {weBuckets} Limit {lmName, lmPerMinute} client = do count bucket = writeTVar weBuckets (Map.insert key bucket buckets) >> pure Nothing -- write the reclaimed map back here too, or a refused request redoes the filtering every time and the map stays at the limit refuse seconds = writeTVar weBuckets buckets >> pure (Just seconds) - case Map.lookup key buckets of - Just bucket@Bucket {bkCount, bkStarted} - | fresh now bucket -> - if bkCount < lmPerMinute - then count bucket {bkCount = bkCount + 1} - else refuse (secondsLeft now bkStarted) - _ -> count Bucket {bkCount = 1, bkStarted = now} - where - secondsLeft :: UTCTime -> UTCTime -> Int - secondsLeft now started = max 1 (ceiling (limitWindow - diffUTCTime now started)) + either (refuse . max 1 . ceiling) count (takeFrom lmPerMinute now (Map.lookup key buckets)) limited :: WebEnv -> Limit -> Request -> Respond -> IO ResponseReceived -> IO ResponseReceived limited env limit req respond action = diff --git a/assets/ASSETS_LICENSE.md b/assets/ASSETS_LICENSE.md index 36ac54d04f..3ce290fc5c 100644 --- a/assets/ASSETS_LICENSE.md +++ b/assets/ASSETS_LICENSE.md @@ -2,11 +2,11 @@ Copyright (C) 2026 SimpleX Chat Ltd. All rights reserved. -The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, and visual designs - are proprietary and are not licensed under the AGPLv3 that covers the application source code. +The graphic assets in this folder, subfolders and in other folders of this repository - including illustrations, images, icons, visual designs, and screenshots of the applications, such as the snapshot images of the tests - are proprietary and are not licensed under the AGPLv3 that covers the application source code. ## Permitted use -- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. +- Unmodified application distribution. You may use these assets as part of the SimpleX Chat application, provided the application is not modified in any way. The snapshot images of the tests are not needed to build the application and are not part of it. - Publications with permission. You may use screenshots containing these assets in publications with prior written permission from SimpleX Chat Ltd. ## Not permitted diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index d9d5a84d26..c4bb2de9cd 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -461,6 +461,9 @@ ServiceNotConfigured: BadgeActive: - type: "badgeActive" +StorePurchaseOpen: +- type: "storePurchaseOpen" + ServiceError: - type: "serviceError" - serviceError: [BadgeServiceErrorCode](#badgeserviceerrorcode) @@ -498,6 +501,7 @@ CredentialNotVerified: - "code_expired" - "receipt_invalid" - "receipt_used" +- "transfer_limited" - "test_purchase" - "internal" diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 2b89ddbcd4..0632587cea 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -429,6 +429,7 @@ undocumentedCommands = "APIRejectCall", "APIReorderChatTags", "APIReportMessage", + "APIRequestBadge", "APIRotateAddressRatchetKeys", "APISaveAppSettings", "APISendCallAnswer", @@ -437,6 +438,7 @@ undocumentedCommands = "APISendCallOffer", "APISendServiceRequest", "APISetAppFilePaths", + "APISetBadgeSubscriptionStatus", "APISetChatItemTTL", "APISetChatSettings", "APISetChatTags", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 7f8a4ac424..ea6f5a47f6 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -209,6 +209,7 @@ undocumentedResponses = "CRSndStandaloneFileCreated", "CRSQLResult", "CRStandaloneFileInfo", + "CRStoreReceiptNotRecorded", "CRTagsUpdated", "CRUsageConditions", "CRUserPrivacy", diff --git a/cabal.project b/cabal.project index 571b5638c7..775eb22a42 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: dcdb8c0f2e4a1eebe359b9ba6ac73a37f71cc6cc + tag: ba941ab5361a6876bd1f2062aa80cc8ac53a3604 source-repository-package type: git diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md index 255f3cd88f..b8b6eba65f 100644 --- a/docs/protocol/badges-overview.md +++ b/docs/protocol/badges-overview.md @@ -1,4 +1,4 @@ -Revision 1, 2026-09-18 +Revision 2, 2026-10-22 # SimpleX Supporter Badges @@ -53,6 +53,8 @@ Two other uses are planned and described under [Future work](#future-work): The badge service holds the purchase key, the number of months bought, and the payment record of the purchase. A card payment is linked to the purchase, as in any other service; a user who wants the payment itself to be private may pay with cryptocurrency. In either case the record is held by the service alone, and the service does not learn where the badge is later shown or used, because a zero-knowledge proof contains nothing that refers back to the credential or the purchase. +An app store subscription's record is its store transaction, which renewals keep, and the charges the store reports for it. When Restore purchases, the app's command that hands the store's current subscriptions to the active profile, moves a subscription to another device or profile, the new purchase key presents the same store transaction as the old one, so the service links the two purchase keys, though not the profiles that hold them. + A party that verifies a proof learns the badge type and the expiry date. Since all credentials expiring in the same week have the same expiry date, these two values place the holder among the supporters whose badges expire that week. ### Comparison with Signal badges @@ -85,14 +87,14 @@ In SimpleX: | Presentations per credential | One | Unlimited | | Proof bound to the context of presentation | No | Yes | | Expiry rounded up to | The next day | The end of the following Monday | -| Issuance and presentation on different days | No | Yes | +| Renewal and presentation on different days | No | Yes | | Usable on independently operated servers | No | Yes | ### Non-goals - A badge does not restrict anything that is available today: the defaults are unchanged, and a badge only raises them. - A badge does not create an identity: it has no persistent identifier, it is not linked across conversations, and an incognito profile does not show it. -- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. +- A badge cannot be transferred: a code can be redeemed once for each use it was issued with (usually one), and the credential obtained with it is usable only with the master key it was issued for. The exception is an app store subscription, which Restore moves to a new purchase key of whichever profile presents its store receipt, and moves again only once the last credential of the key it last moved from has expired. - A badge does not exempt its holder from the limits a server applies: it lowers the cost of a resource without removing the limit on it. - A badge cannot be revoked; credentials are issued for one month at a time instead. @@ -147,7 +149,7 @@ In both cases the app generates the master key and the purchase key pair before ### Monthly issuance -A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. Unused months, e.g. if the app was offline, lapse at the next badge issuance. +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store before issuing each month, so a new charge funds the next month once the paid months have run out, and a refund or revocation the store reports while they run, as in an annual plan, ends the badge; it also asks at once when the app reports a higher tier, which it does for an App Store raise the device sees before the raised charge expires. The app asks about such a raise, or about a lapsed subscription its store charged again, a bounded number of times each time the device hands the charge over, and again after the next hand-over. Any other App Store change of tier is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. On Play another badge is a new purchase: the app starts no plan change, and Play's pages offered none for these products when tested; one made there would be a new purchase under a new token, credited only when Restore hands it over (see [Upgrades](./badges-rpc.md#upgrades)). Unused months, e.g. if the app was offline, lapse at the next badge issuance. Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. @@ -202,8 +204,8 @@ Service requests are protected by the double ratchet of the [SimpleX agent](http 1. A proof discloses no value that links it to another proof or to the purchase. 2. A proof used in one context, whether a session, a conversation or a file, cannot be used in another context. -3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. -4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code and the codes issued in the service's managed group or redeemed by its members, and a request about a purchase can be made only by the holder of the purchase key. +3. The timing of presentations does not identify the holder: all credentials expiring in the same week share the same expiry, and the renewal request and the profile update are made on different days. A new badge (after a purchase, a redemption, a Restore, or a store subscription resumed after it lapsed) is shown as soon as its credential is issued, so for these the request and the profile update are made together. A higher tier is shown as soon as it is issued too, whether the app reported it or a renewal check granted it, since the subscriber raised it in their store; a lower tier granted at a renewal check is shown when the current credential expires, as a renewal is. +4. Requests to the badge service cannot be linked to each other, to a profile or to a network address, other than the redemptions of one multi-use code, the codes issued in the service's managed group or redeemed by its members, and a store subscription moved to a new key by Restore, which the service links to the key it moved from as the same store transaction funds both, and a request about a purchase can be made only by the holder of the purchase key. 5. A credential cannot be forged: the issuer keys are fixed in apps and servers, and the app verifies a credential before storing it. 6. A missing or failed proof leaves the default limit in place, and a server cannot be configured to grant a badge type less than the default. @@ -219,6 +221,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/ - Issue any credential, or refuse to issue one, as it holds the issuer key. - Link a code issued in its managed group to the member who asked for it and to that group, as the group's messages, codes included, are kept in the service's chat database. - Link a redemption to a member of its managed group whose profile shows a new badge soon after it. +- Link the two purchase keys between which Restore moved a store subscription. *cannot:* @@ -289,6 +292,10 @@ An attacker who obtains the credential and the purchase key can use the badge un A code is a bearer secret until its last use is taken; after that, it is refused to any other purchase key. +**Interception of a store receipt** + +Restore moves a store subscription to any new purchase key that presents its store receipt, within the limit on moves, so whoever obtains the receipt can take the badge. The credential already issued on the device it left stays valid until it expires, and then Restore there can move it back. An App Store receipt stops moving the subscription once the store charges again, as only the subscription's latest transaction moves it. A Play purchase token stays the same across renewals, so it moves the subscription for as long as the subscription renews. Each move waits only for the last credential of the key the subscription moved from, so the receipt's holder and the user can take the badge from each other in turn. + **A passive network observer** *can:* @@ -304,4 +311,4 @@ A code is a bearer secret until its last use is taken; after that, it is refused - Backups will be stored on XFTP servers, and a badge will raise their size and their storage time, as described under [What a badge grants](#what-a-badge-grants). - SMP servers and notification servers will verify the proof. A server that verifies it can charge the holder of a badge less for creating queues, file chunks and notification tokens (under the planned proof-of-work scheme, the required effort is divided by a factor configured for each badge type), and can apply higher rate limits and larger quotas. -- Subscriptions that renew automatically, the transfer of remaining months to a new device, and the pausing of a prepaid badge are planned. +- The transfer of a badge bought by card or cryptocurrency to a new device, and the pausing of a prepaid badge, are planned. diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 249baa5431..549085ac54 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -10,9 +10,9 @@ 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`. +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 and a store subscription moved by `transferBadge`, whose old purchase's ledger names the new key. `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 `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. +A purchase record is created by `redeemBadgeCode`, by `getBadgeInvoice` (not built), by `purchaseBadge` funded with `apple` or `google` (or `receipt`, post-MVP), or by `transferBadge`. Those commands accept a key the service holds no record of — on a first purchase it always will, and `transferBadge` requires one. Every other command answers `unknown_purchase_key` for such a key. ## Idempotency @@ -22,52 +22,57 @@ A timeout hides the outcome, so the client repeats the identical signed request - `redeemBadgeCode` — a code already redeemed by the signing key returns its latest `badgeCredential` and writes nothing; once other keys have taken all its uses, `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; a store payment credited to another key, `receipt_used`. - `upgradeBadgeSubscription` — evidence already applied returns the same result and writes nothing. +- `transferBadge` — a payment already moved to the signing key returns the same `badgeCredential`, without asking the store and without the move limit. - `issueBadge` — repeated within an issued period, returns the cached credential and writes nothing. - `purchaseBadge` with the recovery `receipt` — presented again by the same key it returns the same result; presented by another key, `receipt_used`. ## Commands -The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. `upgradeBadgeSubscription` still has the field in the schema; that is wrong and it must be dropped when the command is implemented. +The client never states what is signed. The tier is that of whatever funded the purchase; the expiry is the `sundayAfter` of the period issued (`endOfMondayAfter` in code), shared by every credential of that week; `badgeExtra` is reserved and empty. A client-proposed expiry, even one capped by the funded coverage, would fall off that shared boundary and single its holder out of the week's anonymity set — so no command carries `badgeRequest`. - `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`. - `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 other keys have taken all its uses (a code has one use unless issued with more), `code_expired` past a redemption deadline. - `purchaseBadge` → `badgeCredential` — verifies the funding (`apple` JWS offline; `google` product id and 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. It carries `masterKey` and no `badgeRequest`. The response `receipt` is the recovery bearer secret (model § recovery); the service stores its hash; lifetime badges receive none. - - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. A receipt already credited to the signing key is answered from the record, without asking the store. - - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. - - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good. + - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. An App Store auto-renewable subscription is claimed by its `originalTransactionId`, which every renewal keeps, so all its charges fund one purchase; a Play subscription keeps its token across renewals already. A receipt already credited to the signing key is answered from the record, without asking the store. + - **Subscriptions.** A Play subscription is verified by reading the subscription itself, not by the one-time purchase read; an App Store one by its JWS offline, as any transaction, and then by reading the subscription's status. The latest charge the store reports is credited as a `charge` credit for the months of the plan it was for, dated to the store's billing period start, and the response carries `subscription`. A charge is credited only while its period runs, if it is not revoked, not bought with an offer (on the App Store any offer type; on Play a free-trial or introductory-price phase), for a plan this service sells, while the subscription has not ended, and while the months its plan grants from its period start still run (Play derives that start by clipping at month ends, so a period's last days can fund nothing). Otherwise nothing is written. No record yet bounds how often the store is asked, so a subscription is asked about at most once an hour on this path, an App Store one per original transaction id and a Play one per token, in an hour not shared with `transferBadge`'s: presented to `purchaseBadge` again inside that hour, by any key (and an App Store one as any of its transactions), it is answered `payment_pending` with the time left, and an answer whose `retryAfter` would be shorter carries the hour. Across all requests, this path asks Play about at most 300 subscriptions a minute (one it credits is read once more to acknowledge it): a Play subscription presented when none is left is answered `rate_limited` with the seconds left in the minute, and is not held off. The answers: `payment_pending` with an hour for a period that has not started yet or a store that reports the payment pending; `receipt_invalid` for a Play subscription that has ended, since Play issues a new token when it is bought again; `payment_pending` with a day for everything a later charge on the same receipt could still change — a revoked charge, one in an offer, no charge yet, a period over while the store still retries, a charge whose months from its period start have already run out, a plan this service does not sell, and an App Store subscription that has ended, which a resubscription under the same original id would continue. A charge whose period does not end after it starts is answered `internal`, and a deployment that cannot read a store's subscriptions answers `provider_not_configured`. + - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `rate_limited` for a Play subscription when this path's Play checks for the minute are used; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good, but for an App Store subscription charge, which stops holding the profile a day after its period ends. - **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes. - **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason. - **Pending**, `payment_pending` — a real purchase the store has not settled. - **Unreachable**, `provider_unavailable` — the store was not asked or did not answer. - **Internal**, `internal` — this service's own failure, never the store's answer. - - **App Store.** Apple is verified offline, so it has no "not yet". - - `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked. - - `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction. - - `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. + - **App Store.** Apple's receipt is verified offline, so it has no "not yet"; a subscription's status is then read from the App Store Server API, which can fail to answer. + - `receipt_invalid`: a JWS that is not three base64url parts or names no `transactionId`, or, for an auto-renewable subscription, no `originalTransactionId`; an `x5c` certificate that is not base64; a JWS signature that is not 64 bytes; a leaf whose signature does not verify against the intermediate; a JWS signature that does not verify against the leaf; a transaction with a `revocationDate`, refunded or revoked; and a subscription status whose transaction is family-shared. + - `test_purchase`: refused by the service after the verifier vouched for it, a `Sandbox` transaction, and a subscription status from the `Sandbox` environment. + - `provider_unavailable`, for a subscription's status only: network failure or timeout, and every error response but 401 and 403 — a 404 included, which Apple answers for an original transaction id its environment does not hold — 429 and 5xx among them; and an answer that names no such subscription, as after a Sandbox purchase history is cleared, which is no more certain than that 404. + - `internal`: whatever this service's own configuration decides — an intermediate whose signature verifies against none of the configured roots, and another app's `bundleId`, since a root may be missing or the bundle id wrong as likely as the receipt forged; a transaction from Xcode's StoreKit testing, which Xcode signs and not the App Store, so no configured root verifies it; evidence in a shape this service does not know — a header that does not name `ES256` or carries no `x5c` of exactly three certificates, a certificate that does not decode, a leaf signature the intermediate's key cannot check, an algorithm or key type the library does not support, a chain without Apple's receipt-signing marker extensions, a leaf key that is not P-256, a payload with a missing or mistyped field or an unknown `environment`; the verifier's own failure or overrun; and, refused by the service, a verified transaction other than the one claimed, or a quantity other than 1. For a subscription's status: Apple refusing this service's API token or key (401, 403); an original transaction id that is not all digits; an answer over a mebibyte, or whose list of subscriptions or entry for this subscription cannot be read, an unreadable entry for another subscription being ignored; a signed transaction or renewal info that fails any check above (Apple answered it and no client presented it, so it is never `receipt_invalid`), is not a subscription transaction or renewal info, names another app's bundle id or another subscription, or an unknown environment; and a status this service does not know. - `product_unavailable`: a product this service does not price — the verifier vouched for the transaction either way. - - `provider_not_configured`: the deployment has no `[apple]` section. + - `provider_not_configured`: the deployment has no `[apple]` section, and, for a subscription, an `[apple]` section without an App Store Server API key. - **Google Play.** Only a purchase record is a verdict. - - `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one. - - `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase. - - `payment_pending`: `purchaseState` pending. - - `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` but 401 and 403 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400 or 410 calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away. - - `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` this service does not know; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase. + - `receipt_invalid`: `purchaseState` canceled, which a purchase token never leaves, and a product id outside the characters Play documents, since no product of this app can have one; and a 410 from `purchases.subscriptionsv2.get`, a subscription that expired too long ago to read, on a first purchase (a renewal check answers it as ended). + - `test_purchase`: refused by the service after the verifier vouched for it, `purchaseType` test, a license tester's purchase, and a subscription with `testPurchase`. + - `payment_pending`: `purchaseState` pending, and `SUBSCRIPTION_STATE_PENDING`. + - `provider_unavailable`: network failure or timeout, and every error response from `purchases.products.get` or `purchases.subscriptionsv2.get` except 401, 403 and a subscription check's 410 — a 404 included, which Play may answer both for a token it has just issued and not yet recorded and for one that was never a purchase, and a 400, or a 410 from `purchases.products.get`, calling the token invalid; a 5xx or 429 from the token endpoint; and a token this service will not send — whose characters fall outside the grammar it accepts, which is our guess since Play documents none, or which is only dots, which a URL path would resolve away. + - `internal`: Google refusing this service's credentials or permissions — a 401 or 403 from `purchases.products.get` or `purchases.subscriptionsv2.get`, and any other refusal from the token endpoint; a response that cannot be read, is too large, or names another product; a `purchaseState` or `subscriptionState` this service does not know; a subscription that is not pending with more than one line item, or with none unless it has ended (`SUBSCRIPTION_STATE_EXPIRED` or `SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED`); a charge without its `expiryTime`, product or base plan, and a charge for a base plan this service sells under another product; and a `purchaseType` other than test or promo — a promo code, which only this app's developer issues, is credited like a purchase. - `product_unavailable` and `provider_not_configured` as for the App Store, the latter without a `[google]` section. - - Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself. + - Before crediting a purchase of a product it prices, the service acknowledges it, unless Play already reports it acknowledged or consumed; a subscription is acknowledged with `purchases.subscriptions.acknowledge` before its first charge is credited. It acknowledges and never consumes, so the app's purchase query still lists the purchase. The app may not be honest, and only acknowledgement stops Play refunding the purchase after three days, so nothing is credited unacknowledged. A failed acknowledgement credits nothing and is answered like a failed read — `provider_unavailable` when Play does not answer, `internal` when it refuses this service, both logged with "acknowledging" in the reason — and the app presents the purchase again. A deployment whose service account lacks the permission to acknowledge therefore answers `internal` to every Play purchase the app has not already acknowledged itself. - 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. Its `badgeRequest` is to be dropped (see above). +- `upgradeBadgeSubscription` → `badgeCredential` — an App Store subscription raised to a higher tier, on the same key. The client sends it when the device sees a transaction of the purchase's own subscription for a higher tier, bought after the purchase's last charge credit in its ledger and not expired (see Upgrades); it carries `payment`, that transaction, and `balance`, and makes the service ask the store now rather than at the next renewal check. It is refused before the store is asked when the transaction does not verify (`receipt_invalid`), when it is of no subscription product, of another subscription, of a purchase no App Store subscription funds, or from Play (`bad_request`), and for an unknown key (`unknown_purchase_key`). The store is asked at most once an hour per subscription, an hour shared with the renewal checks. A transaction of the tier the ledger already holds is answered from the record. Otherwise the store is asked and its latest charge is granted as a renewal check grants it, of whatever tier, and the answer is the new tier's credential once that tier is granted; until then the answer is `payment_pending` with a `retryAfter` to the next allowed check, never the old tier's credential. The client retries it a bounded number of times for each hand-over of the transaction, then sets it aside until the device hands it over again. The tier granted is the store's; the transaction only names the subscription and the tier to wait for, so a lower tier sent here is granted only once the store charges it, at its renewal. +- `transferBadge` → `badgeCredential` — Restore on another device or profile: it carries `masterKey` and `payment`, the store receipt of a subscription credited to another key, and moves the subscription to the signing key, which must hold no purchase yet (`bad_request`). The store is asked first, at most once an hour per subscription as for `purchaseBadge` but in an hour of its own, so a Restore right after the purchase is not held off by the purchase's check. An App Store transaction other than the subscription's latest is answered `payment_pending`, so an older receipt stops moving it once the store charges again. The store's current charge must be one a first purchase could be credited, and is answered as for a first purchase when it is not. The old purchase is lapsed and debited `transferOut {toPurchaseKey}` of its remaining months, even none, and is answered `subscription = ended` with no `credential` from then on; the new purchase is credited that charge from its period start, as a first purchase is, and the month due is issued. A payment moves again only once the credential issued to the key it last moved from has expired (`transfer_limited`): a store receipt is not a one-time secret, so this keeps each paid month with at most two keys. A payment credited to no key is credited as by `purchaseBadge`. A one-time purchase is not moved (`receipt_used`): its months were issued at purchase. - `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). + - **Subscriptions.** The service asks the store whenever a month is due, at most once an hour per subscription whether or not the store answers, and answers requests in between from what it last read; a request with no month due is answered as above, without asking. While the paid coverage runs, as an annual plan's does, a store that reports the subscription ended, or the charge it was granted for revoked, ends it: the months not yet issued are debited as `refund`, at the time of the request after a lapse of those that ran out, and the answer is `subscription = ended` with no `credential` — also when the service already read it ended within the hour. Any other answer issues the month due as above, a store that fails or does not answer included, and a new charge it reports is recorded and granted only once the coverage ends. Once coverage has ended, a charge it can grant, by the rule `purchaseBadge` applies to a first charge but never twice, is granted at the store's period start, or at the ledger's last row when that is later, so rows are never dated before the row they follow, after a lapse of what ran out before it, and the month due is issued. A charge of another badge type is granted as that type: what remains of the old run is debited as `upgrade` naming the purchase's own key, the new run starts at that date, and the purchase's badge type follows it. Otherwise no ledger row is written: the charge, if any, and the state are recorded, and the answer carries no new `credential` and the `subscription` the store reported. A store that does not answer is answered `provider_unavailable` with a `retryAfter` of the hour. Every `badgeCredential` answer for a subscription purchase carries `subscription`. A development deployment may ask its store at every request, during coverage too, and bank the months of a charge it can grant, issued once coverage reaches them, and ends it as above; a real store is asked at every request only by a development deployment that also credits test purchases. - `pauseBadge` (post-MVP) → `badgeCredential` — suspends issuance and lapse (model 2.13). ## Upgrades -Always a new purchase under a new key, except store subscriptions, where the store owns the change. +Always a new purchase under a new key, except App Store subscriptions, where the store owns the change and the purchase keeps its key. -- 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. +- Non-store (not built): `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 (not built: `purchaseBadge` with `upgrade` is answered `bad_request`): 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. +- App Store subscription: the client never starts a change; the user makes it in Apple's manage-subscriptions sheet, and it keeps the original transaction id, as all badge subscriptions share one subscription group. A higher tier, which Apple charges at once with a prorated refund, is sent as `upgradeBadgeSubscription` when the device sees its transaction bought after the purchase's last charge credit and not yet expired, however late the device sees it, and is granted as soon as the store reports the charge; each hand-over is retried a bounded number of times, and the next one holds it again. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier whose transaction has expired or is no newer than the last charge credit — the client sends nothing: the service reads the change from the store and grants the badge type the store charged at its next check after coverage ends. +- Play subscription: the client never starts a plan change. Each badge is its own Play product, with a monthly and an annual base plan, and when tested (2026-10-22) Play's own pages offered neither another badge nor another billing period, so another badge is bought as a new purchase once the current one ends. A plan change made in Play's own pages, should one become possible, gets a new purchase token, so it is a new purchase under a fresh key, credited as a first purchase is, and the old token is granted nothing more. No client hands that token over unprompted: the app's sweep finds it in Play's list, but core records a listed token only when it already holds a record of it, so only Restore hands it over, and Restore is refused while the profile shows a badge. Until then it stays unacknowledged, which Play refunds after three days (see Google Play above). ## Catalog @@ -75,9 +80,11 @@ 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. 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. +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 run restarts: one that has lapsed, or a grant of another badge type, which starts a run of its own; months of the same type 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`, `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. +`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. A `charge` credit is dated to the store's billing period start, its `createdAt`, so coverage follows the store's periods, or to the previous entry's `createdAt` when that is later, so dates never decrease; the entry is its grant re-run at that time. An unknown type is stored as received and decoded after an app upgrade. + +`subscription` — present exactly when a store subscription funds the purchase, as the service last read the store, or funded it before `transferBadge` moved it away, then always `ended`: `renewing`; `cancelled`, auto-renewal is off and the paid period still runs; `billingRetry`, a charge failed and the store is retrying it; `ended`, no charge will come. A client reads any other value as not ended. `balance` — `lastEntry`, the client's last entry, asserting the position and the months it believes it holds. @@ -85,4 +92,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, malformed and revoked codes alike, so a guesser learns nothing from the difference; `code_used` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. 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` — all its uses taken by other keys; `code_expired` — past its redemption deadline. `receipt_invalid` covers forged, malformed and refunded receipts alike, so a guesser learns nothing from the difference; `test_purchase` is named apart from them because the device that bought it already knows; `receipt_used` — credited to another key; `transfer_limited` — the subscription moved from a key whose credential is still valid, with no `retryAfter`; `provider_not_configured` — no verifier for that store is deployed, so the receipt is neither credited nor refused. 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 f9ca9d4a14..932dcb0449 100644 --- a/docs/protocol/badges-rpc.schema.json +++ b/docs/protocol/badges-rpc.schema.json @@ -22,12 +22,6 @@ "badgeExpiry": {"type": "timestamp"} } }, - "badgeRequest": { - "properties": { - "masterKey": {"ref": "base64url"}, - "badgeInfo": {"ref": "badgeInfo"} - } - }, "credential": { "properties": { "badgeKeyIdx": {"type": "uint16"}, @@ -230,7 +224,8 @@ "metadata": {"comment": "a redeemed code; its invoice belongs to the buyer, not the redeemer"} }, "charge": { - "properties": {"chargeId": {"type": "string"}} + "properties": {"chargeId": {"type": "string"}}, + "metadata": {"comment": "a store subscription's charge, dated (createdAt) to the store's billing period start, or to the previous entry's createdAt when that is later"} }, "support": { "optionalProperties": {} @@ -321,17 +316,22 @@ }, "upgradeBadgeSubscription": { "properties": { - "badgeRequest": { - "ref": "badgeRequest", - "metadata": {"comment": "known wrong: to be dropped when implemented, see badges-rpc.md"} - }, "payment": { "ref": "payment", - "metadata": {"comment": "store payments only"} + "metadata": {"comment": "apple only: a transaction of the purchase's own subscription"} }, "balance": {"ref": "balance"} } }, + "transferBadge": { + "properties": { + "masterKey": {"ref": "base64url"}, + "payment": { + "ref": "payment", + "metadata": {"comment": "a store subscription's receipt, credited to another key"} + } + } + }, "issueBadge": { "properties": { "balance": {"ref": "balance"} @@ -375,6 +375,10 @@ "receipt": { "type": "string", "metadata": {"comment": "not provided for lifetime badges"} + }, + "subscription": { + "type": "string", + "metadata": {"comment": "SubscriptionState: renewing, cancelled, billingRetry, ended; any other value is read as not ended. Present exactly when a store subscription funds the purchase"} } } }, diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index dc074f3557..4f4215e3f7 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -253,6 +253,7 @@ export type BadgeRedeemError = | BadgeRedeemError.InvalidReceipt | BadgeRedeemError.ServiceNotConfigured | BadgeRedeemError.BadgeActive + | BadgeRedeemError.StorePurchaseOpen | BadgeRedeemError.ServiceError | BadgeRedeemError.InvalidResponse | BadgeRedeemError.UnknownKeyIndex @@ -264,6 +265,7 @@ export namespace BadgeRedeemError { | "invalidReceipt" | "serviceNotConfigured" | "badgeActive" + | "storePurchaseOpen" | "serviceError" | "invalidResponse" | "unknownKeyIndex" @@ -289,6 +291,10 @@ export namespace BadgeRedeemError { type: "badgeActive" } + export interface StorePurchaseOpen extends Interface { + type: "storePurchaseOpen" + } + export interface ServiceError extends Interface { type: "serviceError" serviceError: BadgeServiceErrorCode @@ -326,6 +332,7 @@ export enum BadgeServiceErrorCode { Code_expired = "code_expired", Receipt_invalid = "receipt_invalid", Receipt_used = "receipt_used", + Transfer_limited = "transfer_limited", Test_purchase = "test_purchase", Internal = "internal", } diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 09dcb5a23d..e3d64b6223 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -198,6 +198,9 @@ class BadgeRedeemError_serviceNotConfigured(TypedDict): class BadgeRedeemError_badgeActive(TypedDict): type: Literal["badgeActive"] +class BadgeRedeemError_storePurchaseOpen(TypedDict): + type: Literal["storePurchaseOpen"] + class BadgeRedeemError_serviceError(TypedDict): type: Literal["serviceError"] serviceError: "BadgeServiceErrorCode" @@ -217,15 +220,16 @@ BadgeRedeemError = ( | BadgeRedeemError_invalidReceipt | BadgeRedeemError_serviceNotConfigured | BadgeRedeemError_badgeActive + | BadgeRedeemError_storePurchaseOpen | BadgeRedeemError_serviceError | BadgeRedeemError_invalidResponse | BadgeRedeemError_unknownKeyIndex | BadgeRedeemError_credentialNotVerified ) -BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] +BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "storePurchaseOpen", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] -BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "test_purchase", "internal"] +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "transfer_limited", "test_purchase", "internal"] BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg new file mode 100644 index 0000000000..c7281e8983 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg @@ -0,0 +1,121 @@ + + + + + + + Buying a subscription on Android + The app sends the product id and token; the service reads the subscription from Play and acknowledges it itself, just before crediting. + + + + + + + + + Android app · Play Billing + + Chat core + + Badge service + + Play Developer API + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + + + + + /_badge invoice + + invoiceId + + + + launchBillingFlow: the base + plan's own offer, account id = + invoiceId → purchase token + + + + /_badge purchase {google} + product id + token + + held + + no acknowledgement here for a + subscription (one-time: yes) + + held under the Buy record's + keys, claimed by token hash + + + + purchaseBadge {google} + signed by the purchase key + + + + purchase lane + routed before verifyReceipt + asked < 1 h ago → payment_pending + no Play read left → rate_limited + + + + subscriptionsv2.get(token) + + state; line item: basePlanId, + expiryTime, order id, offerPhase + + + + grantable? start = expiryTime + less the plan's months (clipped); + does it still fund a month now? + + + + read again, then :acknowledge + unless already acknowledged + + acknowledged + + + + one transaction: payment + token, + charge (hashed order id), ledger + (grant at period start), issuance + + + + badgeCredential, subscription: renewing + + + + event: storePurchaseCredited + + finish: nothing to do + + + + Play lists the subscription until it ends; the sweep hands it over as listed and it resolves to the credited record by its token. + If it is never acknowledged (any refusal before step 8), Play refunds it after three days. If anything fails after step 8, + it stays paid and acknowledged, and only a later retry credits it. Renewals keep the token; the service asks Play itself. + Step 8's read is not counted against the 300 subscription checks a minute. + Subscribing again gets a new token: a new purchase. The sweep also hands over tokens core has no record of; core leaves them + for Restore. A retired purchase's token that Play charges again resumes it: up to 24 tries each time the sweep hands it over. + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg new file mode 100644 index 0000000000..a711d34a40 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg @@ -0,0 +1,109 @@ + + + + + + + Buying a subscription on iOS + The app sends Apple's signed transaction; the service checks it offline, then asks the App Store Server API. Nothing is acknowledged. + + + + + + + + + iOS app · StoreKit 2 + + Chat core + + Badge service + + App Store Server API + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + + + + + Transaction.all: subscribed + before? Sold only to resume. + + + + /_badge invoice + + invoiceId + + + + Product.purchase with + appAccountToken = invoiceId + → signed transaction (JWS) + + + + /_badge purchase {apple: jws} + + held; transaction stays unfinished + + held under the Buy record's + keys, claimed by original id + + + + purchaseBadge {apple: jws} + signed by the purchase key + + + + purchase lane (auto-renewable) + JWS verified offline, pinned root + Sandbox → test_purchase + asked < 1 h ago → payment_pending + + + + Get All Subscription Statuses + original id, ES256 token + + signed transaction + renewal info + + + + both verified to the root; + grantable charge → one transaction: + payment, purchase, charge, ledger + (grant at purchaseDate), issuance + + + + badgeCredential, subscription: renewing + stored with the state; badge shown + + + + event: storePurchaseCredited + + transaction.finish() + + + + Renewals: StoreKit lists each renewal's transaction, which the app hands over as listed. It names the same original id, + so it joins the credited record and is finished. The service learns of a new charge only from its own renewal check. + A higher tier newer than the last charge credited is held and sent as upgradeBadgeSubscription (see Changing tier), and a new + charge of the profile's retired purchase resumes it. A revoked subscription transaction is finished without being handed + over. Apple has no acknowledgement step. + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/components.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/components.svg new file mode 100644 index 0000000000..e81cb033bd --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/components.svg @@ -0,0 +1,123 @@ + + + + + + + + Who holds what + The apps buy, restore and report; core holds receipts and the ledger and never calls a store; the service verifies, credits, renews. + + + + + Apps + iOS · Android · desktop + Buy, then hand the receipt to core + /_badge purchase [listed|restored] + Report what the store says + /_badge subscription <state> + Your badge: Renews, Payment + problem, Ends; Manage subscription + Check your order: disclosure, links + Support SimpleX: Restore purchases + One chat-list banner per alert + Desktop: state only, no store rows + + + + + Chat core + client database + badge_store_receipts + held with fresh keys; + hold_kind + badge_purchases + + subscription_status, + unrenewed_since, last_unrenewed_at + badge_ledger + replica; a charge credit = subscription + receipt worker: buy, move, tier raise + badge worker → issueBadge, also at + zero months; derives the alerts; + retires on ended or after 67 days + + + + + Badge service + Three serial lanes: + main · purchase · renewal + Subscription purchases, in memory: + a claim hourly; Play ≤ 300 a minute + payments + + subscription_state, checked_at, + provider_token, rebound_at (moved) + subscription_charges + one row per store charge + badge_ledger + + charge_id, from/to_purchase_id + + + + commands + events + + + + upgradeBadge… + transferBadge + purchaseBadge + issueBadge + credential, + subscription + + + + + buy · list · status + + + + + check · acknowledge + + + + Play Billing + purchase tokens + isAutoRenewing + subscriptions page + + + StoreKit 2 + signed purchases + renewal status + manage sheet + + + + App Store + Server API + statuses by + original id + signed answers + + + Google Play + Developer API + subscriptionsv2 + acknowledge + by token + + + Core never calls a store: the apps + hand it receipts and the device's + view, and the service tells it + what the store charged. + + + + one Apple ID: the same transactions on both sides + + one Google account: the same purchase tokens on both sides + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg new file mode 100644 index 0000000000..a17a36e665 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg @@ -0,0 +1,141 @@ + + + + + + + The service's renewal check + issueBadge for a purchase a subscription funds. The store is asked whenever a month is due, or at once for a raised App Store tier. + + + + issueBadge {lastEntry} + + + Coverage still runs? + paidThrough tip > now + + + Checked within the hour? + subscription_checked_at + + + Stamp the check time first, + in its own write + + + Ask the store, ≤ 10 s + Apple by original id · Play by token + + + Charge grantable now? + grantableCharge, fundsMonthNow + + + + + + + + + + + + + + + no + no + answered + yes + yes + fails + yes + no + + + + + No month due: answer the current credential, + no store call. A month due: checked first (right). + + + + + While coverage runs (annual plans) + + A month due is asked of the store too, at + most once an hour, stamped first. + Ended, or the charge it granted revoked: + one transaction locks, lapses, and debits + the months not yet issued as a refund. + → no credential, subscription ended + A stored ended within the hour: the same. + Any other answer, or a store that fails: + the month is issued; a new charge is only + recorded, granted once coverage ends. + → credential + subscription + + + + Answer from what it last read: no new + credential, the stored subscription state. + + + provider_unavailable, or the store's refusal, + retryAfter, if any, ≥ 1 h; only the stamp written. + + + One transaction, after signing the month: + lock and re-read the tip (moved → internal); + insert the charge; lapse to its period start; + another tier: debit the old run (upgrade); + grant, lapse to now, issue; set tier, state. + → credential + subscription + + + Record the charge, if any, and the state; + no ledger row. → no credential + subscription + + + + Not grantable when + + the state is ended, or there is no charge + the charge was revoked, or is in an offer + its plan is not one this service sells + its period has not started, or is over + its months from the period start ran out + a ledger row already grants it + + A period that ends before it starts: + internal, logged. + + + + + What the app does with the answer + + A credential + stored, shown when the + current one expires; the + unrenewed run ends + + No credential, or an error + the unrenewed run starts or grows; + badge kept until 67 days after the + run started; asked again at the + next daily moment of its expiry + + subscription: ended + badge retired in the same pass; + alert subscription_ended; + nothing more is asked + + No reply (timeout) + nothing retired; the run and the + ledger untouched; the worker + records the failure and retries + on its backoff, 30 s to an hour + + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg new file mode 100644 index 0000000000..90d63e8c1d --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg @@ -0,0 +1,93 @@ + + + + + + + Request lanes and the store limits on each + requestLane reads the request, and for issueBadge and a tier raise the purchase, and never writes. Each lane answers one at a time. + + + + Service requests + one queue, as they arrive + + redeemBadgeCode + getBadgeInvoice + purchaseBadge + purchaseBadge + purchaseBadge + issueBadge + issueBadge + transferBadge + upgradeBadge… + + + Apple, one-time + Apple, auto-renewable + Google, any product + no subscription month due + a subscription month due + Restore, a subscription + due a store check + + + + + + + requestLane + + + + + + + + Main lane + codes, invoices, App Store one-time purchases (verified offline), + other issue requests: prepaid, or no subscription month due + + asks no store + + + + Purchase lane + Play purchases and Restores, and App Store ones whose + unverified payload says auto-renewable. Play one-time: no limit + + Subscriptions, per claim: once an hour, whoever presents it (App + Store by original id after the offline check, Play by token hash) + + Play subscriptions: ≤ 300 checks a minute, then rate_limited + + + + Renewal lane + issueBadge signed by the key it names, for a subscription with + a month due judged 60 s ahead, or whose routing read failed; + upgradeBadgeSubscription of an App Store one due a check + + Per subscription: once an hour, stamped before asking, failures + included; requests in between are answered from the stored state + + + + Stores + + App Store + Server API + + Play + Developer API + each call up to + verifyTimeout, 10 s + + + + + + + A ledger is appended from every lane (a Restore ends the old purchase's on the purchase lane). Each write first locks its purchase's + row, then re-reads the tip, so the lanes may write concurrently at any Postgres --pool-size. + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/restore.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/restore.svg new file mode 100644 index 0000000000..7ba366c559 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/restore.svg @@ -0,0 +1,102 @@ + + + + + + + Restore on another device or profile + Restore moves a store subscription to fresh keys of the active profile; from then on the service answers the old key ended. + + + + + + + + + App on the new device + + Chat core + + Badge service + + App Store or Play API + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + + + + Support SimpleX → Restore + hands over the store's current + subscriptions as restored + + + + /_badge purchase … restored + one per transaction + + + + this profile shows a badge? + → refused: a badge is active + else held as a move, under + fresh keys of this profile + + + + transferBadge {masterKey, payment} + signed by the new purchase key + + + + purchase lane; the key holds no + purchase yet, else bad_request; + asked < 1 h ago → payment_pending; + Play: ≤ 300 checks a minute + + + + the subscription's status + + state and latest charge + + + + App Store, not the latest transaction + → payment_pending. The key it last + moved from holds an unexpired + credential → transfer_limited. A charge + it can't credit → as for a first purchase + + + + one transaction: old purchase lapsed, + debited transferOut {new key}; the + payment moves to the new purchase, + credited from its period start; issue + + + + badgeCredential, subscription + shown and sent to contacts at once + + + + The device it left keeps the credential it holds; its next request is answered ended with no credential, and it retires the badge. + The service now links the two purchase keys, as the same store transaction funds both; it still links neither to a profile. + Whoever holds a store receipt can move the badge to their own keys: an App Store receipt until the store charges again, a Play token + for the subscription's life. A payment moves again only once the last credential of the key it moved from has expired, up to 39 days, + so each paid month stays with at most two keys. The first Restore refused as limited may say nothing; the next says why. + Card, crypto and code badges don't move. + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/retirement-window.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/retirement-window.svg new file mode 100644 index 0000000000..91a46d6383 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/retirement-window.svg @@ -0,0 +1,119 @@ + + + + + + + The retirement window + Two columns, no timer: the rule runs in the badge worker's pass. Days are counted from paidThrough. + + + + + a run starts: unrenewed_since + + answer that renewed nothing + + no reply + + renewal + + retired + + + + days after paidThrough + + + + + + + + + + + 0 + 30 + 60 + 90 + 120 + 150 + + + + + + A persistent fault + every answer renews nothing + + + + + + + + + T0 sets both columns + retired at the first answer ≥ T0 + 67 days, + alert subscription_ended + + 67 days = 60 (longest store recovery) + 7 (contacts' grace) + each answer moves last_unrenewed_at + + + + Away longer than + the window + + + + + app not opened for 75 days + + fresh run: both columns = day 90 + + 75 days ≥ 67 since last_unrenewed_at (day 15) + earliest retirement now day 157 + + + + A renewal ends + the run + + + + + + renewal: the ledger covers now, both columns cleared + + + + past the new paidThrough, the next unrenewed answer starts a new run + + + + The service + never replies + + + + + + + + + + no reply: the run unchanged, nothing retired + a service that never replies keeps the badge; the device's ended retires it only until the request falls due + next answer: + fresh run + + + + + On any pass after which the ledger covers now, the run is cleared. After an answered request that left nothing covered, + last_unrenewed_at moves to now if it is under 67 days old, and otherwise a fresh run starts. Retire if the answer is ended, if + 67 days have passed since unrenewed_since, or, past paidThrough, if the device reported ended. Dots: an open app asks daily. + + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/subscription-states.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/subscription-states.svg new file mode 100644 index 0000000000..bf79091dfc --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/subscription-states.svg @@ -0,0 +1,76 @@ + + + + + + + Subscription states and what each shows + The client keeps the store's state as last observed (badge_purchases.subscription_status); the alert comes from it and paidThrough. + + + The store says + State on the client + What the user sees + + + + + Apple: status 1, auto-renew on + Play: active or paused, auto-renew on + + + renewing + + Your badge: “Renews {date} · {price}”, then “Renewing” + past paidThrough, until the store charges + Banner, annual plans only, 3 days before: + “Your badge renews soon” + + + + Apple: status 1, auto-renew off + Play: active or paused, auto-renew off; canceled + + + cancelled + + Your badge: “Ends {date}”, then “Ended” + Banner while paid: “Subscription cancelled”, + “Your badge stays until {date}” + + + + Apple: status 3 (billing retry), 4 (grace) + Play: in grace period, on hold + + + billingRetry + + Your badge: “Payment problem” + Banner, as a warning: “Badge payment problem” + badge kept, no credential, asked again daily + + + + Apple: status 2 (expired), 5 (revoked) + Play: expired, pending purchase canceled, HTTP 410 + Service: the subscription moved by Restore + + + ended + + From the service: badge retired, banner + “Your subscription ended”. From the device: “Ended”; + past paidThrough the badge is retired too, as above + + + + + Writers: the service, at the credit and in an answer past the asserted coverage or saying ended; and the device's report after each + sweep, which never replaces billingRetry or ended with renewing (Play lists a subscription in its grace period as auto-renewing). + No state yet, or one this version does not know: shown as renewing, no banner. Play's pending subscription: payment_pending, no state. + Also retired without ended: once 67 days have passed since the first answer that renewed nothing, again “Your subscription ended”. + One banner at a time, first that applies: subscription ended, payment problem, renewal failed, cancelled, renews soon. + “Badge renewal failed” (issue_failed) shows a failure that cannot clear, or any once the shown credential has expired. + + diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html new file mode 100644 index 0000000000..bd424ea637 --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html @@ -0,0 +1,162 @@ +Badge Subscriptions + + + +
+
+

PR #7716

+

Badge subscriptions

+

Supporter badges (supporter and legend tiers) were prepaid: a code bought on the web, or a one-month store purchase not yet offered. This PR sells them as App Store and Google Play subscriptions, monthly or annual, across the badge service, core and apps. On iOS this release keeps buying and Restore hidden: the app opens the badge page on the site and redeems the code bought there.

+
+
Who holds what: the apps buy, restore and report; core holds receipts, each held for a purchase, a move or a tier change, and the client ledger, and talks only to the badge service; the service verifies with the App Store Server API and the Play Developer API, credits, renews, changes tier and moves a subscription to another key.
+
+
+ +
+

Buying

+

The app passes the store's receipt, through core, to the badge service, which checks it with the store and credits the badge.

+
    +
  • iOS: the service checks Apple's signature, then asks Apple about the subscription by its original transaction id, which renewals and tier changes keep. An Apple ID that subscribed before can subscribe again only to the badge its profile's last purchase retired, on the same device; core then resumes that purchase.
  • +
  • Android: the service reads the subscription from Play by its purchase token and acknowledges it. Play refunds purchases left unacknowledged for three days.
  • +
+
+
Buying a subscription on iOS: the app creates the record in core, buys with StoreKit, hands the signed transaction to core, core's worker sends purchaseBadge, the service verifies the JWS offline and asks the App Store Server API by original transaction id, then credits the charge; the app finishes the transaction when core reports it credited.
+
+
+
Buying a subscription on Android: the app creates the record in core, buys with Play Billing, hands the token to core without acknowledging it, core's worker sends purchaseBadge, the service reads the subscription from Play by token, acknowledges it just before crediting, and writes the payment, charge and ledger in one transaction.
+
+
+ +
+

Renewals

+

The app requests each month's badge credential at the weekly moment all apps share, then daily, and before issuing a month the service asks the store, at most hourly per subscription. Once the paid months run out, a new charge funds the next month, from its billing-period start and as the tier the store charged; while they run, as in an annual plan, a refund or revocation the store reports ends the badge, its months not yet issued debited as a refund. The app also reports the device's view of the store, such as auto-renewal off, for the badge screen and banners.

+
+
The renewal check: a request with no month due asks no store; otherwise the service asks the store at most once an hour, stamping the check first. During paid coverage a store that reports the subscription ended or the granted charge revoked ends it, the unissued months debited as a refund, and any other answer issues the month. After coverage a grantable charge, of any tier, is written with the issued month in one transaction that locks the purchase and re-reads the tip; otherwise the charge and state are recorded and the answer carries no credential.
+
+
+
Subscription states: renewing, cancelled, billingRetry and ended, what each store state maps to, and the Your badge row and chat-list banner each shows.
+
+
+ +
+

Changing tier

+

The app never starts a change. On iOS the user changes plan in Apple's subscription sheet; all four subscriptions share one group, so the original transaction id, the purchase and its key stay.

+
    +
  • Raising the tier: Apple charges the new tier at once and refunds the rest of the old period. When the device sees that transaction before it expires, core sends it to the service, retrying up to 24 times each time the device hands it over; the service asks Apple, ends the old tier's remaining months and grants the new tier from the charge. The new badge shows at once.
  • +
  • Lowering the tier, or any other change: the app sends nothing, and the renewal check grants what Apple charged; Apple charges a lower tier from the next renewal.
  • +
  • Play: each badge is its own product, the app starts no plan change, and Play's pages offered none in testing; another badge is a new purchase, which the app allows once the current badge has ended.
  • +
+
+
Changing tier in the App Store: core holds a higher tier bought after the last charge credited and not expired as a tier change and sends upgradeBadgeSubscription on the same key, up to 24 times per hand-over; the service asks Apple, ends the old tier's remaining months, grants the new tier from the charge and issues its month, which core shows at once. A lower tier, or a raise whose transaction expired first, waits for the renewal check.
+
+
+ +
+

Restore: moving to another device or profile

+

Restore purchases (Support SimpleX screen; hidden on iOS in this release) hands the store's current subscriptions to core, which asks the service to move one to fresh keys of the active profile, unless that profile already shows a badge: while it is held, core refuses the others, and the app carries on past those refusals, reporting a failure only when nothing was restored. The service asks the store, ends the old purchase, which it answers "ended" from then on, and credits the new key with the current charge; the new badge shows at once. The device it left keeps the badge it holds until that credential is due for renewal, then retires it.

+

A store receipt is no secret: whoever holds it can move the badge to their own keys. So a subscription moves again only once the badge already issued to the key it left has expired, up to 39 days, which keeps each paid month with at most two keys; an App Store receipt moves it only while it is the subscription's latest transaction, a Play token for the subscription's life. The service now links the two purchase keys, as the same store transaction funds both, but still not either to a profile. Badges bought by card, crypto or code don't move.

+
+
Restore on another device or profile: core holds each restored transaction as a move under fresh keys and sends transferBadge; the service refuses an older App Store transaction, a payment moved too recently or a charge it could not credit, and otherwise ends the old purchase, moves the payment and credits the new key, issuing its month.
+
+
+ +
+

How a subscription ends

+

While the store retries a failed payment, the badge stays (on iOS, until the device reports it expired), though contacts see it as expired a week after its last credential expires, and the profile can't buy or redeem another. It is removed as soon as the service answers that the subscription ended; once the paid months have run out, when Apple's store on the device reports it expired; or after 67 days of answers that renewed nothing: 60 days, the longest either store retries, plus 7 days of contacts' grace. If the service never replies and the device reports nothing, the badge stays. A retired subscription the store charges again comes back when the device next hands that charge over, unless the profile has had another badge since: then a hand-over moves it to fresh keys, as Restore does, once no badge shows, and until then the device keeps the transaction unfinished.

+
+
The retirement window on a day scale after paidThrough: the badge retires at the first answer 67 days after the first answer that renewed nothing; a long gap starts a fresh run, a renewal clears it, and requests that get no reply leave it unchanged.
+
+
+ +
+

Protecting the service and the stores

+

Purchases and Restores that ask a store, requests with a month due and tier changes, and everything else run on separate queues, so fake purchases can delay only other purchases and Restores. Each subscription is checked at most hourly per purpose (purchase, Restore, renewal), and the purchase path makes at most 300 Play subscription checks a minute; a Play purchase turned away for three days is refunded.

+
+
Request lanes: codes, invoices, App Store one-time purchases and issue requests with no month due go to the main lane, which asks no store; Play purchases, App Store subscription purchases and Restores to the purchase lane, limited per claim to once an hour and Play to 300 checks a minute; issue requests with a month due and App Store tier raises due a check to the renewal lane, limited to once an hour per subscription. Every write to a ledger locks its purchase first.
+
+
+ +
+

Protocol and database changes

+
    +
  • Badge RPC (still version 1): an optional subscription state in credential answers; charges use the existing charge ledger entry; upgradeBadgeSubscription for a raised App Store tier and transferBadge for Restore, with a transfer_limited error.
  • +
  • Chat API: a command reporting the store's state, listed and restored receipt flags, a refusal while a store receipt may still be credited, and the subscription and its alerts in the badge state.
  • +
  • Databases: four columns in core's, for the store's state, the answers that renewed nothing, and what a held receipt is held for; on the service, a charges table, the charge and the purchases a move links on ledger rows, and state, check time, Play token and move time on payments.
  • +
+
+ +
+

Known limits

+
    +
  • Automated tests use fake stores built from the stores' documentation. On 22 October the lifecycle was walked by hand on an iPhone and an Android phone, against Apple's sandbox and Play's test purchases, with the real verifiers; the review fixes made that night are tested only automatically. Real money, real billing dates and the stores' own retries and refunds are still unexercised.
  • +
  • A purchase completing while another badge shows waits for it to end, and may meanwhile be refunded or lose paid periods.
  • +
  • A Restore refused because the subscription moved recently may say nothing the first time.
  • +
  • Play purchases are acknowledged before being signed and recorded; if either fails, Play won't refund, and only a later successful retry credits them.
  • +
+
+ +
+

Before sale

+

Not built: iOS resubscription on another device or profile or to another badge, Restore for badges bought by card or crypto, seeing a Play refund that leaves the subscription active, and spreading the weekly moment.

+

Store setup: the product ids and every store setting are the store setup checklist in the badge service's apps/simplex-badge-service/README.md. Besides them: server API credentials, enough quota, translations, store review (Sandbox purchases are refused as test purchases, an open question), and products hidden until these apps ship.

+
+
diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md new file mode 100644 index 0000000000..11b313e11f --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md @@ -0,0 +1,73 @@ +# Badge subscriptions (PR #7716) + +Supporter badges (supporter and legend tiers) were prepaid: a code bought on the web, or a one-month store purchase not yet offered. This PR sells them as App Store and Google Play subscriptions, monthly or annual, across the badge service, core and apps. On iOS this release keeps buying and Restore hidden: the app opens the badge page on the site and redeems the code bought there. + +![Who holds what](components.svg) + +## Buying + +The app passes the store's receipt, through core, to the badge service, which checks it with the store and credits the badge. + +- **iOS:** the service checks Apple's signature, then asks Apple about the subscription by its original transaction id, which renewals and tier changes keep. An Apple ID that subscribed before can subscribe again only to the badge its profile's last purchase retired, on the same device; core then resumes that purchase. +- **Android:** the service reads the subscription from Play by its purchase token and acknowledges it. Play refunds purchases left unacknowledged for three days. + +![Buying on iOS](buy-ios.svg) + +![Buying on Android](buy-android.svg) + +## Renewals + +The app requests each month's badge credential at the weekly moment all apps share, then daily, and before issuing a month the service asks the store, at most hourly per subscription. Once the paid months run out, a new charge funds the next month, from its billing-period start and as the tier the store charged; while they run, as in an annual plan, a refund or revocation the store reports ends the badge, its months not yet issued debited as a refund. The app also reports the device's view of the store, such as auto-renewal off, for the badge screen and banners. + +![The renewal check](renewal-check.svg) + +![Subscription states](subscription-states.svg) + +## Changing tier + +The app never starts a change. On iOS the user changes plan in Apple's subscription sheet; all four subscriptions share one group, so the original transaction id, the purchase and its key stay. + +- **Raising the tier:** Apple charges the new tier at once and refunds the rest of the old period. When the device sees that transaction before it expires, core sends it to the service, retrying up to 24 times each time the device hands it over; the service asks Apple, ends the old tier's remaining months and grants the new tier from the charge. The new badge shows at once. +- **Lowering the tier, or any other change:** the app sends nothing, and the renewal check grants what Apple charged; Apple charges a lower tier from the next renewal. +- **Play:** each badge is its own product, the app starts no plan change, and Play's pages offered none in testing; another badge is a new purchase, which the app allows once the current badge has ended. + +![Changing tier](tier-change.svg) + +## Restore: moving to another device or profile + +Restore purchases (Support SimpleX screen; hidden on iOS in this release) hands the store's current subscriptions to core, which asks the service to move one to fresh keys of the active profile, unless that profile already shows a badge: while it is held, core refuses the others, and the app carries on past those refusals, reporting a failure only when nothing was restored. The service asks the store, ends the old purchase, which it answers "ended" from then on, and credits the new key with the current charge; the new badge shows at once. The device it left keeps the badge it holds until that credential is due for renewal, then retires it. + +A store receipt is no secret: whoever holds it can move the badge to their own keys. So a subscription moves again only once the badge already issued to the key it left has expired, up to 39 days, which keeps each paid month with at most two keys; an App Store receipt moves it only while it is the subscription's latest transaction, a Play token for the subscription's life. The service now links the two purchase keys, as the same store transaction funds both, but still not either to a profile. Badges bought by card, crypto or code don't move. + +![Restore](restore.svg) + +## How a subscription ends + +While the store retries a failed payment, the badge stays (on iOS, until the device reports it expired), though contacts see it as expired a week after its last credential expires, and the profile can't buy or redeem another. It is removed as soon as the service answers that the subscription ended; once the paid months have run out, when Apple's store on the device reports it expired; or after 67 days of answers that renewed nothing: 60 days, the longest either store retries, plus 7 days of contacts' grace. If the service never replies and the device reports nothing, the badge stays. A retired subscription the store charges again comes back when the device next hands that charge over, unless the profile has had another badge since: then a hand-over moves it to fresh keys, as Restore does, once no badge shows, and until then the device keeps the transaction unfinished. + +![The retirement window](retirement-window.svg) + +## Protecting the service and the stores + +Purchases and Restores that ask a store, requests with a month due and tier changes, and everything else run on separate queues, so fake purchases can delay only other purchases and Restores. Each subscription is checked at most hourly per purpose (purchase, Restore, renewal), and the purchase path makes at most 300 Play subscription checks a minute; a Play purchase turned away for three days is refunded. + +![Request lanes](request-lanes.svg) + +## Protocol and database changes + +- **Badge RPC** (still version 1): an optional subscription state in credential answers; charges use the existing `charge` ledger entry; `upgradeBadgeSubscription` for a raised App Store tier and `transferBadge` for Restore, with a `transfer_limited` error. +- **Chat API:** a command reporting the store's state, `listed` and `restored` receipt flags, a refusal while a store receipt may still be credited, and the subscription and its alerts in the badge state. +- **Databases:** four columns in core's, for the store's state, the answers that renewed nothing, and what a held receipt is held for; on the service, a charges table, the charge and the purchases a move links on ledger rows, and state, check time, Play token and move time on payments. + +## Known limits + +- Automated tests use fake stores built from the stores' documentation. On 22 October the lifecycle was walked by hand on an iPhone and an Android phone, against Apple's sandbox and Play's test purchases, with the real verifiers; the review fixes made that night are tested only automatically. Real money, real billing dates and the stores' own retries and refunds are still unexercised. +- A purchase completing while another badge shows waits for it to end, and may meanwhile be refunded or lose paid periods. +- A Restore refused because the subscription moved recently may say nothing the first time. +- Play purchases are acknowledged before being signed and recorded; if either fails, Play won't refund, and only a later successful retry credits them. + +## Before sale + +Not built: iOS resubscription on another device or profile or to another badge, Restore for badges bought by card or crypto, seeing a Play refund that leaves the subscription active, and spreading the weekly moment. + +Store setup: the product ids and every store setting are the store setup checklist in the badge service's [README](../../apps/simplex-badge-service/README.md#store-setup). Besides them: server API credentials, enough quota, translations, store review (Sandbox purchases are refused as test purchases, an open question), and products hidden until these apps ship. diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg new file mode 100644 index 0000000000..0b25418f5f --- /dev/null +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg @@ -0,0 +1,97 @@ + + + + + + + Changing tier in the App Store + The user changes plan in Apple's sheet; the original transaction id stays, so the purchase and its key stay. Supporter to legend shown. + + + + + + + + + iOS app · StoreKit 2 + + Chat core + + Badge service + + App Store Server API + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + + + + + Manage subscription → Apple's + sheet: legend charged now, + the rest of supporter refunded + + + + /_badge purchase … listed {apple} + the new transaction, same original id + + + + a higher tier, newer than the last + charge credited, not expired? + yes → held as a tier change + no → answered from the record; + the renewal check grants it later + + + + upgradeBadgeSubscription + the transaction + balance, same key + + + + its own subscription, not yet legend; + asked < 1 h ago → payment_pending; + else, on the renewal lane: stamp, ask + + + + Get All Subscription Statuses + + latest charge: legend, its period + + + + one transaction: debit the rest of + the supporter run (upgrade), grant + legend from the charge's period + start, issue its month; tier = legend + + + + badgeCredential: legend + before the grant: payment_pending, never supporter + + + + shown and sent to contacts at + once, not at the old expiry + + + + Any other change (a lower tier, which Apple charges from the next renewal, or a change of plan length): the app sends nothing, and + the renewal check after the old coverage ends grants what Apple charged. A raise whose transaction expired unseen waits the same way. + A held raise is sent up to 24 times each time the device hands it over, then set aside until the next hand-over. + Play: each badge is its own product, with no plan change from the app or, in testing, Play's pages; another badge is a new purchase. + diff --git a/plans/2026-10-13-badge-subscription-renewals.md b/plans/2026-10-13-badge-subscription-renewals.md index 056c059810..b91dc733a6 100644 --- a/plans/2026-10-13-badge-subscription-renewals.md +++ b/plans/2026-10-13-badge-subscription-renewals.md @@ -1,5 +1,7 @@ # Plan: store subscription renewals for supporter badges +For reviewers: a summary of the code as built in PR #7716, with diagrams, is in [`2026-10-13-badge-subscription-renewals-sketches/summary.md`](2026-10-13-badge-subscription-renewals-sketches/summary.md). Where it and this plan differ, the summary describes the code. + Run: 001-badge-subscriptions · Base branch: `f/badge-subscriptions`, cut from `badge-in-app-checkout` once `f/badge-store-verifiers` is merged into it · Repos: simplex-chat-2 · Estimated diff: ~1700 changed lines (~900 code and docs, ~800 tests), one PR in eight slices This run makes App Store and Google Play subscriptions renew badges: in the badge service, including its real connections to Apple's App Store Server API and Google Play's subscriptions API, and in the shared app core. It changes no iOS or Android screens, so nothing here puts a subscription on sale. @@ -85,7 +87,7 @@ The core change: - A subscription purchase is an ordinary purchase whose ledger is refilled by store charges instead of being paid once. - Its store identity is the subscription, not one transaction: Apple's original transaction id (Play's token already is). Every renewal therefore resolves to the same purchase and the same receipt record on both sides, and the service has one thing to ask the store about. -- Months enter a subscription ledger only as `charge` credits whose operation time is the store's billing period start. The grant row's `createdAt` is that period start, as `grantEntry` already stamps its `t`. Coverage follows the store's periods without drift, every row verifies by re-running its operation at its `createdAt`, and `StatementEntry` gains no field. +- Months enter a subscription ledger only as `charge` credits whose operation time is the store's billing period start. The grant row's `createdAt` is that period start, or the previous row's `createdAt` when that is later, so dates never decrease. Coverage follows the store's periods without drift, every row verifies by re-running its operation at its `createdAt`, and `StatementEntry` gains no field. - The first charge and every renewal go through one pure planner: lapse what ran out before the period start, grant the charge's months at the period start, lapse what ran out since, then issue at now. - The service asks the store only when a subscription purchase's paid coverage has ended, and writes nothing unless a charge is granted. - The client keeps asking at the shared weekly moment and keeps the badge until the service answers that the subscription has ended. @@ -114,7 +116,7 @@ The core change: - `Badges/Ledger.hs`: - `entryTypeFromColumns` takes the row's charge id and rebuilds `SCCharge` from it (slice 2). - A pure planner (suggested name `chargeEntries`) takes the tip or a seed, the charge's period start, its months, the charge id, `now` and fresh entry ids. It returns `lapse periodStart` (if any), `grant periodStart months (SCCharge chargeId)`, `lapse now` (if any). The caller issues at `now` with `issueEntry` as today. - - The rows' `createdAt` are non-decreasing whenever the period start is not before the tip's `createdAt`. That holds on every path: the tip was issued before its month ended, the next charge's period starts around that month's end, and the service writes no row while a subscription waits for its charge. + - The rows' `createdAt` are non-decreasing because the planner dates a charge at the later of its period start and the tip's `createdAt`, so a charge asked about late in its period is dated after the row it follows. ### Badge service (`apps/simplex-badge-service/src/BadgeService/`) @@ -327,7 +329,7 @@ None. The timing and grace behaviour, the branch, who runs builds, tier changes, - The Apple verifier checks no certificate validity dates or revocation. - A Play subscription purchase is offered by the first offer on its base plan, not a named offer (`PlayStore.kt`). - Retirement does not signal the receipt worker. -- After a renewal that lands once the shown credential has expired (billing-retry recovery), the request and the profile update happen in the same pass, because `presentIssuedBadge` presents as soon as the shown credential has expired. This already happens to late prepaid renewals; it weakens objective 3 for recovered subscriptions. +- After a renewal that lands once the shown credential has expired (billing-retry recovery), the request and the profile update happen in the same pass, because `presentIssuedBadge` presents as soon as the shown credential has expired. This already happens to late prepaid renewals; it weakens objective 3 for recovered subscriptions. A raised tier is presented at once as well, whichever request granted it (the receipt worker's upgrade request in `creditStoreReceipt`, a renewal check in `presentIssuedBadge`): the subscriber raised the tier in their store, so the broadcast tells the store nothing new. A lower tier keeps the hold-back until the shown credential expires. ## 11. Follow-up runs diff --git a/plans/2026-10-20-badge-ledger-transitions.md b/plans/2026-10-20-badge-ledger-transitions.md new file mode 100644 index 0000000000..26988653ff --- /dev/null +++ b/plans/2026-10-20-badge-ledger-transitions.md @@ -0,0 +1,157 @@ +# Badge subscriptions: tier changes and restore + +Base: `f/badge-subscriptions` at 26f846e1a (line numbers at that commit). `SVC/` = `apps/simplex-badge-service/src/BadgeService/`. "Model" = `plans/2026-07-30-supporter-badges-v3-ux.md`. + +## Summary + +**What.** Two gaps that block selling subscriptions: + +- **Tier change.** All four Apple subscription products share one subscription group, so a user can switch Supporter ↔ Legend in App Store settings. Apple keeps the same subscription and charges the new price; the service records the charge and grants nothing. We grant the tier the store charged. +- **Restore.** A subscriber on a new phone, or on another profile, taps Restore and gets `receipt_used`, because the payment belongs to the old install's purchase key. We let Restore move the subscription to the new key. + +**Why together.** Both move months between tiers or between purchase keys. The ledger entry types for that exist (`transferIn`, `transferOut`, `upgrade`), but the service can neither store nor re-read them, and a grant cannot change a balance's tier. + +**How.** + +- Tier change: on a charge of the other tier, the service ends the old tier's remaining months with an `upgrade` debit and starts a new run of months at the new tier. It finds out at the next renewal check, or, for a raise, at once when the app sees the new App Store transaction and asks (`upgradeBadgeSubscription`, already in the protocol, unimplemented). +- Restore: a new request, `transferBadge`, signed by a fresh key and carrying the store receipt. The service moves the payment to the new key: `transferOut` on the old purchase; the store's current charge credited to the new one, with the current month issued. A payment moves again only once the credential issued to the key it last moved from has expired: a store receipt is not a secret, so this limit is the only safeguard. +- Stripe and crypto purchases are restored by a receipt code (once per receipt, unissued months only): designed here, built later; it does not block subscriptions. + +**Where.** Service (`SVC/Service.hs`, `SVC/Store.hs`, migrations); shared ledger (`src/Simplex/Chat/Badges/Ledger.hs`); core (`Library/Commands.hs`, `Store/Badges.hs`); the Restore button in both apps. + +**New types and wire changes.** + +- `BSCTransferBadge {masterKey, payment}` and error `transfer_limited`. +- `BSCUpgradeBadgeSubscription` implemented; its `badgeRequest` field dropped (never sent). +- `StoreReceiptOrigin` gains `SRORestored`; `AppleTransactionFacts` gains `productId`. +- Ledger: `debitEntry`; `grantEntry` and `chargeEntries` take the grant's tier. No new entry types. + +**Schema.** + +- Service: `badge_ledger.from_purchase_id`, `badge_ledger.to_purchase_id`; `payments.rebound_at` (when the payment was last moved to another key, logged; the limit itself is checked against the previous key's latest credential expiry, which the service signed, and `transfer_limited` carries no date); charge index `UNIQUE(charge_id)` → `UNIQUE(badge_purchase_id, charge_id)`. +- Client: `badge_store_receipts.hold_kind` (purchase, transfer, tier change). +- Later, receipts: `badge_purchases.receipt_hash` (service), `badge_purchases.receipt` (client). + +## Current state + +- **Ledger.** The service ledger's only reference columns are `payment_id` and `charge_id`; `appendLedgerPlan` writes only the tag of the three types (`SVC/Store.hs:318-353`), and `entryTypeFromColumns` cannot rebuild them (`Ledger.hs:220-244`). One unreadable row makes every later answer for that purchase `internal` (`SVC/Store.hs:282-300`). The client stores each entry's JSON and needs no column (`Store/Badges.hs:551-616`). +- **Tier.** Fixed per purchase: `current_badge_type` is written once on both sides; every transition keeps the previous tier (`Ledger.hs:76-117`); the service refuses another tier (`NGOtherTier`, `SVC/Service.hs:631`). On the device, a new Apple transaction for the same subscription is answered from the old record and finished, without asking the service (`Commands.hs:5377-5380`). +- **Restore.** A payment belongs to one purchase key; any other key gets `receipt_used`, which the client treats as final (`SVC/Service.hs:666-671`, `Commands.hs:5409-5414`). The client marks a purchase as a subscription only when its ledger holds a `charge` credit (`Store/Badges.hs:464-492`). +- **Builds on:** retirement on `ended` (commit 55d69781e), and the same-install resume being built on this branch (`reholdStoreReceipt`, `resumeWindow`, `sendBadgeIssue`), whose re-hold T3 and R2 reuse. + +## Design + +### Ledger (L1, L2) + +- **Storage (L1).** `appendLedgerPlan` writes `from_purchase_id`/`to_purchase_id` from the entry's key; the reads join `badge_purchases` for the keys; `entryTypeFromColumns` takes one record of references and rebuilds all three types. +- **Transitions (L2).** + - `debitEntry`: all remaining months to zero, start unchanged (the model's `debit`). + - A grant of another tier is allowed only onto an empty balance, and starts a new run at its own time with the new tier. So the model's "start never decreases" now holds within a tier. + - `chargeEntries` for another tier, at `t = max periodStart (createdAt tip)`: lapse to `t`, `upgrade {own key}` debit if months remain, grant at `t`, lapse to now. Clamping `t` keeps `createdAt` non-decreasing. + - The client's check re-runs `debitEntry` for `upgrade` and `transferOut`, and marks false a tier switch over a non-empty balance. + +### Tier change (T1, S0, T2, T3) + +- **At renewal (T1).** `NGOtherTier` is removed: a charge of the other tier is granted through `chargeEntries`, and both sides update `current_badge_type`. On its own this fixes downgrades, which take effect at renewal. +- **At once (T2, T3).** An Apple upgrade is immediate, and the unused part of the old plan is refunded pro rata. Without T2 and T3 an annual subscriber would pay for Legend for up to 11 months while holding Supporter. + - S0 moves `storePlan` (product → tier and months) into shared code, so core can read a transaction's tier. + - T3: when a handed-over Apple transaction raises the tier of the shown purchase, was bought after the purchase's last charge credit and has not expired, core re-holds the record as a "tier change". The app keeps the transaction unfinished, and the receipt worker sends `upgradeBadgeSubscription`. A raised tier's credential is presented at once, on this path and on T1's renewal check alike: the subscriber raised the tier in their store, so the broadcast tells the store nothing new. A lower tier keeps the renewal's hold-back, presented when the shown credential expires. Any other transaction, a lower tier or an expired one, is answered from the record and finished, and T1 catches the change at renewal. + - T2 (service): the request must be signed by the purchase key and name its own subscription. The store is asked at most once an hour per subscription, sharing the renewal check's stamp. The answer is the new tier's credential once granted; until then `payment_pending` (within the hour, or while Apple's status still shows the old product), never the old credential. +- **Authority.** The tier granted is the one in the store's own verified answer. The JWS the app sends only names the subscription and what to wait for. +- **Apps.** Nothing new is required: Your badge shows `badgeState.badgeType`, which now follows the ledger. An optional iOS notice "Changes to Supporter on {date}" (T4) shows a pending downgrade. + +### Restore for store subscriptions (R1a, R1b, R2, R3) + +**`transferBadge`, signed by a new key K2, for payment P held by K1** — one transaction: + +- K1: lapse, then `transferOut {K2}` of its unissued months. It is written even at zero, as the marker that makes K1's next answer `subscription = ended`, so K1's badge retires. +- K2: a new purchase funded as a first purchase of P would be: the store's current `charge` credit at its period start, then the current month issued. No `transferIn`: the client would then read K2 as prepaid and stop asking for renewals. +- P's binding moves to K2; `rebound_at = now`; the move is logged. +- Store one-time months are not re-bound: they are issued at purchase, and no app path lists them again. + +**Check order**, so a request re-sent after a lost answer gets its credential: + +1. P already credited to K2: answered from the record, with no store call and no limit. +2. K2 already holds another purchase: `bad_request`. +3. The store vouches for the receipt now. For Apple it must be the subscription's latest transaction, so a leaked old receipt stops working at the next renewal. +4. The limit: `transfer_limited`. + +A payment credited nowhere is treated as a first purchase. `purchaseBadge` from another key still gets `receipt_used`, so the automatic sweeps on two devices never move the badge back and forth; only the Restore button transfers. The request is routed to the purchase lane, since it calls the store. + +**Limit: a payment moves again only once the credential issued to the previous key has expired.** That credential stays valid until then, so each paid month is held by at most two keys, however many devices or people share the store account. The bound is the credential's own expiry rather than a calendar month, because a calendar month has an edge: a move on 31 January and another on 1 February are both allowed, and since a credential outlives a month, three keys would briefly hold valid ones. It does not stop someone holding the account, or a receipt from it, from moving the badge to themselves each time a credential lapses; the store account is the authority, as the stores themselves treat it. + +**Core (R2).** Restore hands receipts over with the new origin `restored`: + +- no record → a held transfer record with fresh keys; +- a held purchase record that funds no purchase yet → becomes a transfer; +- a record refused `receipt_used` → re-held as a transfer; +- a subscription record credited to another profile → re-held for this profile with fresh keys; a one-time record is left alone; +- resume and tier-change holds, and this profile's own retired subscription while it is the profile's newest purchase → left to the resume; an older one, or one whose payment moved away → re-held as a transfer with fresh keys. + +The worker sends `transferBadge` for transfer records. On success the record links to the new purchase; on a refusal it goes back to the old one. + +**Apps (R3).** Restore hands over `Transaction.currentEntitlements` and `Transaction.unfinished` on iOS, and Play's subscriptions on Android, with the `restored` origin. The result shows through the existing "being added" state and alerts. One new text, for `transfer_limited`: "This purchase was moved to another device or profile recently." + +### Restore by receipt for Stripe and crypto (later, R4-R6) + +- **R4 (service).** Each code or web purchase that grants months returns a receipt code; the service stores its hash. +- **R5 (service).** `transferBadge` with `SPReceipt` moves only the unissued months: `transferOut` on K1, `transferIn {K1}` on K2 starting now. The receipt is retired and a fresh one is returned. +- **R6 (apps).** The app stores the receipt, shows it with a "keep your receipt" prompt, and adds a "Move a badge from another device" entry. + +### Threats + +| Threat | Handling | +|---|---| +| Tier change the user did not intend | Granted exactly as the store charged; undone through the store (switch back, refund). The apps never offer a change. | +| Client claims a tier it did not pay for | The tier comes from the store's verified answer, not the JWS. | +| Replayed upgrade or transfer request | Upgrade: answered from state within the hour, and each charge is granted once. Transfer: answered from the record (check 1). | +| Leaked or replayed store receipt | A payment moves again only once the previous key's credential has expired; an Apple receipt expires at the next renewal; every move is logged. A Play token has only that limit. | +| Two devices racing a transfer | The write re-reads K1's tip and P's binding, and writes nothing if either moved. | +| Linking | A transfer links K2 to K1 at the service; the store account already links them. Design objective 4 in `badges-overview.md` gains this exception. Requests follow the user's own action, as purchases do. | + +## Slices + +One PR, built in these slices in order; T3 and R2 need the same-install resume on the branch first. Each slice runs `cabal build --enable-tests` and the suites named. + +| Slice | Change | Tests | Lines | +|---|---|---|---| +| L1 | service reference columns + migration; rebuild the three types | `testEntryTypeColumns`, `testTransferEntriesRoundTrip`, schema down/up (`-m "Supporter badges" -m "badge service schema" -m "badge service store"`) | ~150 | +| L2 | `debitEntry`, tier-aware `grantEntry`/`chargeEntries`, client check | `testDebitRowsVerify`, `testTierChangeRowsVerify` (`-m "Supporter badges"`) | ~200 | +| T1 | grant other tier at renewal; update `current_badge_type` both sides | `testSubscriptionTierChangeAtRenewal`; drop the other-tier case from `testSubscriptionChargeNotGranted` and from the resume's bounded test (`-m "SimpleX badge service e2e/subscriptions/"`) | ~200 | +| S0 | move `storePlan` to shared code (structural) | existing suites | ~60 | +| T2 | `upgradeBadgeSubscription` handler, renewal lane | `testSubscriptionUpgradeNow`, `testUpgradeRequestRefused`, `testStoreRouting` | ~250 | +| T3 | `productId`, `hold_kind` migration, tier-change hold and worker send | `testAppleTierChangeHandOver`, `testAppleTierChangeStoreLags` | ~300 | +| R1a | `rebound_at`, charge index, move-binding store function | schema down/up (these changes are in `20261012_badge_subscriptions`, whose down migration drops them and every other subscription column without refusing, and the service README warns against it on a database holding subscriptions; whether it should refuse there is the owner's open call), store test | ~150 | +| R1b | `transferBadge` handler, `ended` after `transferOut`, docs | `testStoreRebind`, `testStoreRebindCapped`, `testStoreRebindOldKeyEnded`, `testStoreRebindStaleAppleTransaction`, `testTransferUncredited`, `testTransferReplayAnswered`, `testTransferKnownKeyRefused` | ~300 | +| R2 | `SRORestored` and the restore cases in core | `testRestoreOnNewDevice`, `testRestoreOnOtherProfile`, `testRestoreAfterUnfinishedPass` | ~300 | +| R3 | Restore in iOS and Kotlin, `gaps.md` | build both apps; restore on a second device and profile | ~150 | + +**Blocks selling subscriptions:** every slice above. **Does not block:** T4, R4-R6. + +Final checks: `-m "badge"` here; the full suite (query plans) and the Postgres build and schema tests on the host. + +## Risks + +- **Riskiest assumption:** that restarting the run at the clamped `t` keeps every row verifiable on all paths. L2's pure tests check this before any service code depends on it. +- **Apple behaviour from documentation:** an upgrade is immediate, the old plan is refunded pro rata, and the subscription keeps its original id; a downgrade waits for renewal; `currentEntitlements` returns the latest transaction. Confirm in Sandbox before sale. If upgrades are not refunded, the `upgrade` debit becomes a conversion instead. +- A restore soon after an earlier move may be refused by the move limit, which the app reports with no date, as `transfer_limited` carries none; one presenting an Apple transaction older than the subscription's latest is answered `payment_pending` and stays held until a hand-over brings the latest. + +## Decided + +- One Apple subscription group for all four subscription products, so users can change tier in the store and this plan grants it. + +## Open questions (the slices assume the recommended answers) + +1. **The move limit.** Decided: a payment moves again only once the credential issued to the previous key has expired, which is what bounds how many keys hold a valid credential at once. Rejected: once per calendar month, whose month boundary lets two moves through in two days; once per billing period, which would mean once a year for annual plans. +2. **Re-issue the current month on a store restore.** Recommended: yes. Otherwise a monthly subscriber, or an App Reviewer, sees nothing until the next renewal. +3. **Require the latest Apple transaction for a restore.** Recommended: yes. +4. **Keep a tier change on the same key**, where the model's §2.10 uses new keys. Recommended: the same key. The service sees changes made in the store's settings without the client, and only the client can make keys. + +## Rejected alternatives + +- **Re-bind on any `purchaseBadge` from another key:** two devices would move the badge back and forth on every launch. +- **`transferIn` for a store restore:** the client would treat the subscription as prepaid and stop asking for renewals. +- **Unissued months only for stores:** a restore mid-month would show nothing. +- **Send the upgrade request from the hand-over itself:** it blocks the app's purchase sweep, and a network error becomes a "Purchase error" alert. +- **Ask the store at every monthly issuance:** twelve times the store calls for annual plans, and still up to a month late. +- **Move a purchase between profiles of one install locally:** more private for that case, but it does not cover a new device, and it would be a second path. diff --git a/plans/2026-10-28-desktop-macos-theme-detector-log.md b/plans/2026-10-28-desktop-macos-theme-detector-log.md new file mode 100644 index 0000000000..bcd613d77e --- /dev/null +++ b/plans/2026-10-28-desktop-macos-theme-detector-log.md @@ -0,0 +1,23 @@ +# Desktop: macOS theme detector logs an error in light mode + +## Problem + +On macOS the app prints `ERROR com.jthemedetecor.MacOSThemeDetector - Couldn't execute theme name query with the Os` with a stack trace. + +## Cause + +`MacOSThemeDetector.isDark()` reads `AppleInterfaceStyle`, which macOS leaves unset in light mode. `toStringViaUTF8` then returns `null`, `isDarkTheme(null)` throws `NullPointerException`, and the library logs it through slf4j-simple and returns `false`. The library catches the exception itself, so the app's `catch (e: Exception)` is never reached. Read from the jSystemThemeDetector 3.8 and jfa 1.2.0 bytecode; the unset key in light mode is not verified on a Mac. + +## Fix + +`simplelogger.properties` turns off the `com.jthemedetecor.MacOSThemeDetector` logger. This also hides that logger's other error ("Observer method cannot be added") and any other `RuntimeException` that `isDark()` catches; `isDark()` returns `false` as before. The oshi and Gnome loggers still print. + +## Approaches considered + +1. Turn off the logger (chosen): slf4j-simple can only set a level per logger, so this is the smallest change. +2. Read `AppleInterfaceStyle` in the app instead of the library: removes the cause, but is larger and cannot be tested here. +3. Bump jSystemThemeDetector to 3.9.1, which adds the null check: a different change. The same release rewrites the Gnome detector (it also watches `color-scheme`, and its loop does not block when `readLine` returns `null`, so it busy-loops once the `gsettings` process exits), which would need its own Gnome and shutdown-hook testing. + +## Scope + +Desktop only (`desktopMain`); in effect on macOS only. diff --git a/plans/2026-10-28-desktop-startup-without-jna.md b/plans/2026-10-28-desktop-startup-without-jna.md new file mode 100644 index 0000000000..675224b7d7 --- /dev/null +++ b/plans/2026-10-28-desktop-startup-without-jna.md @@ -0,0 +1,30 @@ +# Desktop starts when JNA cannot load + +## Problem + +On desktop the app exits before any window opens when the JNA native library cannot be loaded, for example when the JNA temp directory (`~/.cache/JNA/temp`) is on a `noexec` mount. The log shows `NoClassDefFoundError: Could not initialize class com.sun.jna.Native` from `Resources_desktopKt.`, caused by `UnsatisfiedLinkError: ... failed to map segment from shared object`. System theme detection is optional, yet it ends the startup. + +## Cause + +- `Resources.desktop.kt` (bf91666f0, #4109) and `Theme.desktop.kt` (26a233ab1, #2755) each create the detector in a top-level `val` (`OsThemeDetector.getDetector()`). +- `getDetector()` loads oshi, which loads JNA. The failure is a `LinkageError` thrown from a static initializer, outside the `try` around `isDark`, so nothing catches it. +- `Resources.desktop.kt` also declares `settings`, so the first preference read (`AppPreferences`) triggers the failing initializer. + +## Fix + +- `systemThemeDetector` in `Resources.desktop.kt` replaces the private `detector`: `getDetector()` inside `try`, `catch (_: LinkageError)` returns `null`. `WindowActivation.kt` catches `LinkageError` for the same condition (#7605). +- It stays an eager top-level `val`, so the detector is created at the same point as before. It does not log: `Log.w` reads `appPrefs`, and `AppPreferences` is under construction when `Resources_desktopKt` initialises (the first `settings` read), so logging there re-enters `appPrefs` and fails with a `NullPointerException`. On Linux oshi has already logged the failure; on Windows and macOS nothing is logged (not verified). +- `isInNightMode()` and `isSystemInDarkTheme()` return `false` (light theme) when there is no detector, the same value the existing `catch (e: Exception)` returns. +- In `Theme.desktop.kt`, `detector` is initialised from `systemThemeDetector`; `getDetector()` returns one cached instance, so both files already shared it. The change listener and the gsettings shutdown hook (#7747) are registered only when a detector exists. + +## Approaches considered + +1. Shared eager accessor with a `LinkageError` guard (chosen). +2. Lazy accessor that logs a warning: no re-entrancy, but moves the detector's start-up cost (about 190 ms measured on the baseline) from `runMigrations` to the first composition on the UI thread. +3. A guard in each file: the same fact (how to continue when JNA cannot be loaded) stated twice. +4. Guard only `Resources.desktop.kt`, the file in the report's trace: `Theme.desktop.kt` would throw the same error when the window starts. +5. Point JNA at an executable temp directory at launch: a different change (running native code extracted into a user-writable directory) and it does not help when mapping fails for another reason. + +## Scope + +Desktop only (`desktopMain`); `spec/services/theme.md` is updated to the new code. Not changed: JNA temp directory, VLC/video behaviour without JNA. A JNA native library of another version selected through an explicit `jna.boot.library.path` or `jna.nosys=false` throws `java.lang.Error`, not `LinkageError`, and is not caught; the app sets neither property. diff --git a/scripts/badge-service/badge_service.ini.example b/scripts/badge-service/badge_service.ini.example index f0dea005ac..6728a4f9e3 100644 --- a/scripts/badge-service/badge_service.ini.example +++ b/scripts/badge-service/badge_service.ini.example @@ -40,9 +40,16 @@ idle_seconds = 60 ; root_certificates lists, comma-separated, the roots trusted to sign receipts, each in DER and placed ; in config/. Today that is https://www.apple.com/certificateauthority/AppleRootCA-G3.cer. ; When Apple adds a root, list it beside the old one rather than in its place. +; the api_ keys, set together, verify subscriptions with the in-app purchase key App Store Connect +; issues; without them every App Store subscription is answered provider_not_configured. Place the .p8 +; in config/; it must be readable by the service's user alone. api_host defaults to production, the +; only host accepted unless [dev] credit_test_purchases = on, which needs the Sandbox host instead. ;[apple] ;bundle_id = chat.simplex.app ;root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer +;api_key_id = 2X9R4HXF34 +;api_issuer_id = 57246542-96fe-1a63-e053-0824d011072a +;api_private_key_file = /etc/simplex-badge-service/AuthKey_2X9R4HXF34.p8 ; optional; without it every Play receipt is answered provider_not_configured, which the app retries later. ; service_account_file is the JSON key of a service account with the Play Console permissions "View @@ -72,6 +79,29 @@ private_key = replace-me ; local testing only, in both run modes: accepts any well-formed App Store or Play receipt without ; verifying it, so anyone who can reach this service can mint badges. The service refuses to start -; with this on beside an [apple] or [google] section. +; with this on beside an [apple] or [google] section. Its issuer key is always one released apps +; trust, so they accept every badge minted this way. +; A subscription is answered renewing and charged for its plan: the App Store product's, or the base +; plan a Play test token test.. names when it is a plan of that product, and else +; the product's monthly base plan, so a licence tester's purchase is credited too. An App Store tier +; raise is charged for the raised plan, which its renewals then charge. The plans are kept in memory, +; so a restart answers later renewals as ended, and refunds the unissued +; months of a plan still covered at its next month. Nothing is acknowledged, so Play refunds a +; licence tester's subscription once its acknowledgement window lapses. [dev] accept_unverified_store_receipts = off +; only beside accept_unverified_store_receipts or credit_test_purchases = on: every issue request for a +; subscription asks its store, while coverage runs too, and banks the months each check charges, to +; watch a renewal by hand. +check_store_during_coverage = off + +; local testing only, beside a real [apple] or [google]: credits a purchase the store itself calls a +; test - an App Store sandbox purchase, or a Play licence tester's - which no real buyer pays for. +; Without it such a purchase is refused as test_purchase, which is terminal, so it can never be credited. +; Beside [apple] the service starts with this on only when its api_ keys are set with +; api_host = https://api.storekit-sandbox.itunes.apple.com, since a sandbox receipt verifies like any +; other and would otherwise be credited as a real purchase. Beside [google] it is allowed: never turn it +; on beside the production app's [google], as the issuer key is one released apps trust and every +; licence tester the Play Console lists would get a badge without paying. Each test purchase or test +; subscription taken as paid is logged as a warning. +credit_test_purchases = off diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 5b5e6d4351..61370815ae 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -4,7 +4,9 @@ Three workflows build the apps from a branch's own code and test them, only when ## Reduced build -GitHub runs the reduced build of `build.yml` on a dispatch (workflow_dispatch) and on each push to a draft pull request. Once the pull request is marked ready for review, GitHub runs the full matrix. +GitHub runs the reduced build of `build.yml` on a dispatch (workflow_dispatch), on each push to a draft pull request and on a push to master or stable in a fork. Once the pull request is marked ready for review, GitHub runs the full matrix, as on a push to master or stable in simplex-chat/simplex-chat. + +On a push to master or stable in a fork, the release steps and tests are skipped; build-linux saves the -O0 build caches, and on master the Docker image cache, that the fork's dispatches restore. On a draft pull request, the tests are selected by a fenced block with the info string `ci-tests` in the description, one hspec match pattern per line; the block is read at each push. `/` runs the whole suite; without a block, the reduced build runs no tests. @@ -110,7 +112,6 @@ The reduced build runs one Linux job, build-linux (ubuntu-22.04 x86_64), for eve - Globs: - scripts/ci/packages_release.py - scripts/ci/test_packages_release.py -- These paths are also in the core globs (scripts/ci/**). - CI, in build-linux: `scope-tests.sh ci-scripts` (step "Test changed scopes"). - Local: `python3 scripts/ci/test_packages_release.py`; Python 3.11 or newer is required for tomllib (packages_release.py). @@ -150,7 +151,7 @@ Pushes start none of the app workflows: a run is started by hand, on any branch, | Input | iOS | Desktop | Android | |---|---|---|---| -| no input | scripted test | scripted test | scripted test | +| no input | scripted test, the app uploaded | scripted test, the app uploaded | scripted test, the app uploaded | | `claude` | scripted test, then Claude | scripted test, then Claude | scripted test, then Claude | | `backend` | also the test backend | also the test backend | also the test backend | | `release` | — | also the latest release's onboarding, to tell a branch's failure from the runner's | also the latest release's onboarding, to tell a branch's failure from the runner's | @@ -166,6 +167,8 @@ gh workflow run android-emu.yml --ref -f claude=true A newer run of a workflow on the same branch cancels the older one. +Each run uploads the app it built, for manual tests, as an artifact kept 14 days: `ios-sim-app` (SimpleX.app.tar.gz, for the iOS simulator on an Apple-silicon Mac), `desktop-app` (desktop-app.tar.gz, for Linux x86-64) and `android-app` (the debug APK, arm64-v8a only). The run's page lists it under Artifacts; `gh run download -n ` downloads it. + ## How long a run takes | Run | iOS | Desktop | Android | diff --git a/scripts/ci/android/README.md b/scripts/ci/android/README.md index 106a147a22..ceac2a1b08 100644 --- a/scripts/ci/android/README.md +++ b/scripts/ci/android/README.md @@ -19,7 +19,7 @@ Every run builds the core library, and the backend when it's asked for, from the | `backend` (opt-in) | desktop-linux's `build-backend.sh` | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's arm64 APK, its signer checked against SimpleX's key, onboarding only | about 4 min | -Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for the Claude job, is kept a day. The job summaries list the checks and the Claude usage. +Results are artifacts: `android-smoke-test` (numbered screenshots, the accessibility tree and the text of each, `steps.log`, `checks.txt`, logcat as `app.log`, `emulator.log`), `android-release-smoke-test`, `android-claude-test`, and the backend's logs; `android-app`, the APK for manual tests and the Claude job, is kept 14 days. The job summaries list the checks and the Claude usage. ## The emulator diff --git a/scripts/ci/desktop-linux/README.md b/scripts/ci/desktop-linux/README.md index 918e0d68ce..bd6176f0c7 100644 --- a/scripts/ci/desktop-linux/README.md +++ b/scripts/ci/desktop-linux/README.md @@ -14,21 +14,33 @@ Every run builds the core library, and the backend when it's asked for, from the |---|---|---| | `plan` | chooses the jobs, finds earlier core and backend builds | 10 s | | `core` | `scripts/desktop/build-lib-linux.sh` via `build-core.sh` | skipped when reused, about 24 min not | -| `app` | the app (`:desktop:createDistributable`), desktopTest, `smoke-test.sh` | about 12 min: build 4.5, desktopTest 0.5, smoke test 3.5 | +| `app` | the app (`:desktop:createDistributable`), desktopTest with the snapshots, `smoke-test.sh` | about 12 min: build 4.5, desktopTest 0.7, smoke test 3.5 | | `claude` (opt-in) | after `app` passed: its distributable at the first screen, then Claude Code drives it | about 4 min, Claude 3 | | `backend` (opt-in) | `build-backend.sh`: `simplex-chat`, `simplex-directory-service`, and `smp-server` from the simplexmq commit `cabal.project` pins | about 30 min, skipped when reused | | `release` (opt-in) | the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min | -Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for the Claude job, is kept a day. The job summaries list the checks, the desktopTest results and the Claude usage. +Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: `desktop-smoke-test` (numbered screenshots, the text OCR read from each, `steps.log`, `checks.txt`, `app.log`), `desktop-test-report`, `desktop-snapshots` (below), `desktop-claude-test`, and the backend's logs; `desktop-app`, the distributable for manual tests and the Claude job, is kept 14 days. The job summaries list the checks, the desktopTest results and the Claude usage. ## Scripts -- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. +- `ui`: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display. `ui` alone prints its commands: start (with app settings), shot, text, find, wait, click, right-click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop. `screen-text.py`, `screen-colour.py` and `screen-ink.py` are its helpers. - `smoke-test.sh [start|onboarding|setup|backend]`: the scripted cases; the default is onboarding and both layouts. `setup` also creates a local group and makes the test SMP server the app's only names server; `backend` does that, connects to the seeded name, and checks both layouts. - `backend.sh start ` / `stop `: the test backend, below. -- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend), `claude-guard.py`, `claude-usage.py`: the Claude job. +- `claude-test.sh`, `claude-test.md` (the prompt), `claude-scope` (whether the prompt uses the test backend, and optionally the session's turns and budget), `claude-guard.py`, `claude-usage.py`: the Claude job. - `build-core.sh`, `install-core.sh`, `build-backend.sh`, `earlier-run.sh`, `release-app.sh`, `test-summary.py`: builds, reuse and reports. +## Snapshots + +`:common:desktopTest` runs `SnapshotTest` (`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SnapshotTest.kt`). It renders each preview listed in `previewScenes` with Compose's `ImageComposeScene`, 402 dp wide at density 1, one pixel per dp, as tall as the preview's content up to 874 dp, so a preview that fills any height is 874 pixels tall. It renders the light and dark themes without a wallpaper, over the theme's background, and keeps the fourth frame, 3 s of frame time after the first. As the app does for its screens, the test provides an `AppBarHandler` (`LocalAppBarHandler`) and the window state (`simplexWindowState`, sized as the scene). Each preview is rendered at the default font size; the chat item previews (package `views.chat.item`) also at 1.4 and 1.5, the two largest values of the desktop app's font size setting. + +`everyPreviewIsRendered` reads the Kotlin sources of `commonMain` and `desktopMain` without comments, and fails when a `@Preview` function is missing from both `previewScenes` and `previewsCallingCore`, or when either names a function absent from the sources; a scene is named `.`. `previewsCallingCore` lists the previews whose layouts validate names or descriptions with the core library (`chatValidName`, `chatJsonLength`), which the test JVM does not load, so they are not rendered. The previews of `androidMain` and of the Android app module are left out too, since they use Android APIs. + +The test runs in UTC and en_US (the JVM's default `TimeZone` and `Locale`), and replaces the theme overrides with ones without a wallpaper. It sets the app's theme and font size for each render, and restores these settings and defaults when it finishes. Before each render it clears the terminal's items, which previews that send commands to the core add with the time they are sent, so that `PreviewTerminalLayout` shows an empty terminal. Previews show `previewDate` (`ChatItemView.kt`) in place of the current time. + +The images go to `SNAPSHOT_DIR`, otherwise to `apps/multiplatform/common/build/snapshots`, as PNG files named `.--font-<100|140|150>.png`, with `index-desktop.json` listing each file's scene, theme, font scale, and pixel width and height. The app job sets `SNAPSHOT_DIR` to `snapshots/` of the artifact `desktop-snapshots`, then the step "Compare snapshots" runs `scripts/ci/snapshots-compare.py` with the references in `apps/multiplatform/common/src/desktopTest/snapshots`, as the iOS workflow does ([iOS](../ios-sim/README.md), "Snapshots"): a missing, extra or resized render, or a pixel beyond the tolerance, fails the step, and the diff images go to `snapshots-diff/` of the same artifact. + +`scripts/ci/snapshots-remote.py desktop record|check [branch]` starts `desktop-linux.yml` on a pushed branch, waits for the run and downloads `snapshots/` and `snapshots-diff/` of its `desktop-snapshots`: `record` replaces the PNG files in `apps/multiplatform/common/src/desktopTest/snapshots` with the run's renders, for review and commit; `check` prints the files that failed the run's comparison. + ## The test backend `backend.sh` starts, on the runner, with CLI peers: @@ -41,7 +53,7 @@ It writes the directory's address to `/directory-link` and the SMP server's ## The Claude job -It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with `--max-turns 60`, `--max-budget-usd 3` and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`). Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. +It needs the GitHub environment `app-sim-claude` with the secret `CLAUDE_CODE_OAUTH_TOKEN`, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs `claude`; `claude-test.sh` passes it to the `claude` process alone, and every `ui` call unsets it too. The job starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since this prompt doesn't use the backend. The job installs Claude Code at the version the iOS workflow pins too, and runs `claude-haiku-5-5` with the turns and budget `claude-scope` sets (`--max-turns 60` and `--max-budget-usd 3` unless it sets `max-turns:` or `max-budget-usd:`) and the iOS sessions' other flags (`--restricted`, `--permission-prompts none`, `--effort high`); the job's time limit is 120 minutes. Its Bash runs only single `ui` commands (`claude-guard.py`, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, `claude-test.sh` checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with `CLAUDE-TEST: PASS`; `report.md`, the transcript, the guard's log and `claude-usage.json` are in `desktop-claude-test`. ## Cost diff --git a/scripts/ci/desktop-linux/claude-guard.py b/scripts/ci/desktop-linux/claude-guard.py index 7b004584b2..fb0099c448 100755 --- a/scripts/ci/desktop-linux/claude-guard.py +++ b/scripts/ci/desktop-linux/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") call = json.load(sys.stdin) command = (call.get("tool_input") or {}).get("command", "").strip() -allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", command)) +) if os.environ.get("CLAUDE_GUARD_LOG"): with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log: log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n") diff --git a/scripts/ci/desktop-linux/claude-scope b/scripts/ci/desktop-linux/claude-scope index ca4c2da0f9..548ea0c988 100644 --- a/scripts/ci/desktop-linux/claude-scope +++ b/scripts/ci/desktop-linux/claude-scope @@ -1,3 +1,6 @@ # What the Claude job needs for the session claude-test.md describes. # backend: yes when the prompt uses the test backend; a run that asks for Claude must then start it too. +# max-turns and max-budget-usd (optional) bound the session; claude-test.sh uses 60 and 3 when they're absent. backend: no +max-turns: 300 +max-budget-usd: 5 diff --git a/scripts/ci/desktop-linux/claude-test.md b/scripts/ci/desktop-linux/claude-test.md index 94982805f1..b2c3e99798 100644 --- a/scripts/ci/desktop-linux/claude-test.md +++ b/scripts/ci/desktop-linux/claude-test.md @@ -1,52 +1,73 @@ -# Test: onboarding and the chat list in the SimpleX Chat desktop app +# Test: channel message signing and channel moderators in the SimpleX Chat desktop app You are testing the SimpleX Chat desktop app, which runs on a virtual display on this machine. Drive it only with the `ui` helper, through Bash, one command per call: `ui [args]`. Anything else is refused; don't look for ways around that, and if something blocks the test, report it. The commands are listed at the end. -Your working directory is where your output goes. `ui shot ` saves `screens/NN-.png` and prints the text read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words; when the text and the picture disagree, trust the picture. "Above" means a smaller y. +Your working directory is where your output goes. `ui shot ` saves `screens/NN-.png` and prints the text read from it, one phrase per line after its centre `x,y` in screen pixels. Read the PNG to see the screen (1440×900, the app window at the top left). OCR can misread or split words and can't read icons; when the text and the picture disagree, trust the picture. "Above" means a smaller y. ## What is set up - The app is already running, on its first screen, with a new, empty database: never run `ui start` or `ui stop`. Begin with `ui shot start` to see it. -- After onboarding, the chat list is in the left pane (x 1 to 389), and the right pane shows the empty chat list's cards. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. In the default "Reachable app toolbars" layout, the toolbar and the search field are at the bottom of the left pane. +- After onboarding, the chat list is in the left pane (x 1 to 389) and chats open in the right pane. An empty chat list shows only its toolbar, with the profile picture at about 27,730 and a pencil (New chat) at about 363,730: the search field "Search or paste SimpleX link" appears once there is a chat. The toolbar is at the bottom of the left pane. +- The channel uses the app's preset chat relays, on the internet. Creating it and joining it wait for them: wait up to 120 s there (`ui wait "" 120`). + +## How to do things in the app + +- **Send a message:** click the message field at the bottom of the right pane (its placeholder is "Broadcast" for the channel's owner, "Message" for others), `ui type ""`, then `ui key Return`. Check the message then shows in the chat. +- **Channel info:** click the channel name at the top of the right pane. Press Escape to close it and other panes (preferences, member info, a message's info) and get back to the chat. +- **Message menu:** `ui right-click ` on the message's text (centre from `ui find`) opens its menu ("Info", "Moderate", …). +- **Signatures are read as text, never from the seal icons:** a message's "Info" shows "Signed" (your own message), "Signed & verified" (someone else's, checked) or "Signature missing". +- **Switch profile:** click the profile picture (about 27,730); the menu lists your other profiles by name: click the one you want. The chat list then shows that profile's chats; click "ci signing" in it. +- **Add a profile:** profile picture → "Your chat profiles" → "Add profile". On "Create profile", click the field about 40 px below the text "Enter your name:", type the name, and click the "Create profile" button (the lower of the two "Create profile" texts; the upper one is the title). The new profile becomes the active one; press Escape until the chat list shows. +- **Join by the copied link** (from a profile without chats): the pencil (about 363,730) → "Paste link" → "Tap to paste link". An alert names the channel with "Open channel": click it. The channel opens with "Tap Join channel" and a "Join channel" bar at the bottom: click the bar, and wait up to 120 s until the bottom of the chat shows "you are subscriber". ## The test -**A. Onboarding.** Each screen waits for the one before it; wait for a text before clicking it. +The parts build on each other: run them in this order. cisub must join only after cimod has become a moderator (B2), so that cimod learns about cisub. -1. The first screen has "Create your profile": click it. -2. On "Create profile", click the field "Enter profile name", type `citester`, and click "Create profile". -3. The next screen offers a database passphrase: click "Use random passphrase", then "OK" in the alert that follows. -4. Click "Continue", then "Accept" on the screen after it. -5. Wait up to 60 s for the chat list: the right pane shows "Talk to someone". Take a shot. +**S. Setup.** -**B. A group, and the chat list with the toolbar at the bottom.** +1. (S1) Onboarding. Each screen waits for the one before it; wait for a text before clicking it. The first screen has "Create your profile": click it. On "Create profile", click the field "Enter profile name", type `citester`, and click "Create profile". The next screen offers a database passphrase: click "Use random passphrase", then "OK" in the alert that follows. Click "Continue", then "Accept" on the screen after it. Wait up to 60 s for the chat list: the right pane shows "Talk to someone". +2. (S2) Click the pencil, then "Create public channel". Click the field about 40 px below the text "Channel name", type `ci signing`, and click the "Create public channel" button (the lower of the two texts with that name). Wait up to 120 s for "Share link": the channel opens and its "Channel link" pane shows over it. If "N/M relays active" stays below M for 120 s with N at least 1, click "Continue", and "Continue" in the alert. On "Channel link", click "Continue". Check: the chat "ci signing" is open. +3. (S3) Send `before signing`. Check: it shows in the chat. -1. Click the pencil (about 363,730) to open New chat, then "Create group". Click the name field, about 40 px below the text "Enter group name", type `seeded notes`, and click "Create group". If "Skip inviting members" shows, click it. -2. The group opens in the right pane, and stays there. Press Escape until the left pane shows the chat list again, then take a shot. -3. Check, noting the evidence (screenshot names, positions): - - "seeded notes" is listed in the left pane; - - the search field "Search or paste SimpleX link" is at the bottom of the left pane (y over 600), and "Add list" is just above it; - - nothing is cut off, overlapping or garbled. +**A. "Sign messages" (as citester).** -**Between the layouts:** click the profile picture at the left end of the bottom toolbar (about 27,730) to open the menu, open "Settings", then "Appearance", and turn off the switch of "Reachable app toolbars", at the right end of its row (about x 335). Press Escape until the chat list shows again; its search field should now be at the top. +1. (A1) Channel info → "Channel preferences". Check: the first row is "Sign messages", and its footer says "Do not require signing messages." (the switch is off). +2. (A2) Click the switch of "Sign messages", at the right end of its row. Check: the alert "Sign messages?" says "Messages sent before will show "signature missing" to subscribers who join later." and has "Enable" and "Cancel". Click "Cancel". Check: the footer says "Do not require signing messages." again. +3. (A3) Click the switch again, then "Enable". Check: the footer says "Require signing messages.". Click "Save and notify channel subscribers", then press Escape until the chat shows. Check: the chat shows "Sign messages: on". +4. (A4) Send `after signing`, right-click it → "Info". Check: the info says "Signed". +5. (A5) Channel info → "Channel preferences". Check: the footer of "Chat with admins" says "Prohibit chats with admins." (saving in A3 did not turn it on). Press Escape until the chat shows. -**C. The chat list, toolbar at the top.** Check, noting the evidence: +**J. A subscriber joins.** -- "seeded notes" is still listed; -- the search field is at the top of the left pane (y under 200), and "Add list" is just below it; -- nothing is cut off, overlapping or garbled. The pencil is now a floating button at the bottom right of the left pane, drawn over whatever the list shows there: note what it covers, but that alone is not a failure. +1. (J1) Channel info → "Channel link" → "Share link". Check: the toast "Copied to clipboard". Press Escape until the chat list shows. +2. (J2) Add the profile `cimod`, and as cimod join by the copied link. Check: "you are subscriber". +3. (A6) As cimod in "ci signing": click the message "before signing". Check: the alert "Signature missing" says "The message was sent before the channel started signing messages, or it is fake." Close it, right-click "before signing" → "Info". Check: "Signature missing". Then right-click "after signing" → "Info". Check: "Signed & verified". -You have at most 60 turns, and the whole test fits in about 25 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: +**B. A moderator.** + +1. (B1) Switch to citester → "ci signing" → channel info → "Subscribers" → "cimod". Click the "Change role" row. Check: the list offers exactly "subscriber", "contributor" and "moderator" (no admin, no owner). +2. (B2) Click "moderator", then "Change" in the alert "Change role?". Check: cimod's role shows moderator, and the section above it is titled "Moderator". Press Escape until the chat shows. Check: the chat shows "you changed role of cimod to moderator". +3. (B3) Switch to cimod → "ci signing". Check: the chat shows "changed your role to moderator". Send `moderator post`. Check: it shows in the chat. +4. (J3) Add the profile `cisub`, and as cisub join by the same link (it is still on the clipboard; if not, copy it again as citester as in J1). Check: "you are subscriber". +5. (J4) Switch to citester → "ci signing" → channel info → "Subscribers" → "cisub" → "Change role" → "contributor" → "Change". Check: cisub's role shows contributor, in the section titled "Contributor". +6. (J5) Switch to cisub → "ci signing". Send `contributor post`. Check: it shows in the chat. +7. (B4) Switch to cimod → "ci signing" → channel info. Check: it shows "Subscribers"; click it: the list includes "cisub". +8. (B5) Click "cisub". Check: its info offers "Block for all" (don't click it yet). Press Escape until the chat shows. +9. (B6) Right-click "contributor post" → "Moderate" → "Delete" in the alert "Delete member message?". Check: the message shows "moderated by cimod" in its place. +10. (B7) Channel info → "Subscribers" → "cisub" → "Block for all" → "Block for all" in the alert "Block subscriber for all?". Check: cisub's info now offers "Unblock for all". Press Escape until the chat shows. Check: the chat shows "you blocked cisub". + +You have at most 300 turns, and the whole test fits in about 150 if you chain steps whose outcome you can predict with `ui do` and look at the screen after each chain. For example: - `ui do click-text "Create your profile" -- wait "Enter profile name" -- click-text "Enter profile name" -- type citester -- shot profile` -- `ui do click-text "Create profile" -- wait "Use random passphrase" 60 -- shot passphrase` -- `ui do click 363 730 -- click-text "Create group" 10 -- wait "Enter group name" -- shot new-group` -- `ui do click 27 730 -- click-text Settings 10 -- click-text Appearance 10 -- wait "Reachable app toolbars" -- shot appearance` +- `ui do click 363 730 -- click-text "Create public channel" 10 -- wait "Channel name" -- shot new-channel` +- `ui do click-text "Broadcast" -- type "after signing" -- key Return -- wait "after signing" -- shot after-signing` +- `ui do click 27 730 -- click-text "Your chat profiles" 10 -- click-text "Add profile" 10 -- wait "Enter your name" -- shot add-profile` -`ui do` stops at the first command that fails, so a failed chain tells you where it stopped. +`ui do` stops at the first command that fails, so a failed chain tells you where it stopped. Take a shot at each check, named after its step (`ui shot A2`). ## Report -Write `report.md` in your working directory: for each of A, B and C, each check you ran as PASS or FAIL with its evidence, and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the checks that failed. +Write `report.md` in your working directory as you go, so a session that is cut short still leaves its results: one row per step (S1 to S3, A1 to A5, J1, J2, A6, B1 to B3, J3 to J5, B4 to B7), each check you ran as PASS or FAIL with its evidence (the text OCR read, screenshot names), and anything else unexpected. End your final message with one line: `CLAUDE-TEST: PASS` if every check you ran passed, otherwise `CLAUDE-TEST: FAIL` followed by the steps that failed. ## The ui helper diff --git a/scripts/ci/desktop-linux/claude-test.sh b/scripts/ci/desktop-linux/claude-test.sh index 6f70e6400f..730a374c72 100755 --- a/scripts/ci/desktop-linux/claude-test.sh +++ b/scripts/ci/desktop-linux/claude-test.sh @@ -23,6 +23,13 @@ settings=$(mktemp) export CLAUDE_GUARD_LOG=$transcript.guard.log : > "$CLAUDE_GUARD_LOG" +scope() { + sed -n "s/^$1: *//p" "$here/claude-scope" +} + +max_turns=$(scope max-turns) +max_budget=$(scope max-budget-usd) + { cat "$prompt" echo @@ -47,8 +54,8 @@ CLAUDE_CODE_OAUTH_TOKEN=$token claude -p \ --permission-mode dontAsk \ --permission-prompts none \ --strict-mcp-config \ - --max-turns 60 \ - --max-budget-usd 3 \ + --max-turns "${max_turns:-60}" \ + --max-budget-usd "${max_budget:-3}" \ --autocompact 200k \ --no-session-persistence \ --settings "$settings" \ diff --git a/scripts/ci/desktop-linux/ui b/scripts/ci/desktop-linux/ui index 83c6f74bc0..1ef9c21940 100755 --- a/scripts/ci/desktop-linux/ui +++ b/scripts/ci/desktop-linux/ui @@ -14,6 +14,7 @@ # ui ink print the height in pixels of the text's ink, to compare text sizes # ui wait [secs] wait up to secs (30) until the text is on screen # ui click click at x,y +# ui right-click right-click at x,y, e.g. to open a list row's menu # ui click-text [secs] wait up to secs (20) for the text and click its centre # ui scroll [clicks] # turn the mouse wheel over x,y, 3 clicks unless given @@ -155,8 +156,17 @@ shot() { log "shot $(basename "$f").png" } +number() { + if ! [[ "$1" =~ ^[0-9]+$ ]]; then + echo "error: \"$1\" is not a number" + exit 1 + fi +} + click() { - xdotool mousemove --sync "$1" "$2" click 1 + number "$1" + number "$2" + xdotool mousemove --sync "$1" "$2" click "${3:-1}" sleep 1 } @@ -171,6 +181,9 @@ scroll() { return 1 ;; esac + number "$1" + number "$2" + number "$4" xdotool mousemove --sync "$1" "$2" click --repeat "$4" --delay 100 "$button" sleep 1 } @@ -223,6 +236,7 @@ case "${1:-}" in ;; wait) secs=${3:-30} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "wait \"$2\": not on screen after $secs s" exit 1 @@ -234,8 +248,13 @@ case "${1:-}" in click "$2" "$3" log "click $2,$3" ;; + right-click) + click "$2" "$3" 3 + log "right-click $2,$3" + ;; click-text) secs=${3:-20} + number "$secs" if ! pos=$(wait_for "$2" "$secs"); then log "click-text \"$2\": not on screen after $secs s" exit 1 @@ -255,7 +274,16 @@ case "${1:-}" in ;; key) shift - xdotool key --delay 100 -- "$@" + for k in "$@"; do + if ! [[ "$k" =~ ^[A-Za-z0-9_]+(\+[A-Za-z0-9_]+)*$ ]]; then + echo "error: \"$k\" is not a key name" + exit 1 + fi + done + # one key per call: xdotool key runs any later argument that names one of its commands, such as exec + for k in "$@"; do + xdotool key --delay 100 -- "$k" + done log "key $*" ;; do) diff --git a/scripts/ci/ios-sim/README.md b/scripts/ci/ios-sim/README.md index c79acfaa59..67eee816fd 100644 --- a/scripts/ci/ios-sim/README.md +++ b/scripts/ci/ios-sim/README.md @@ -6,14 +6,14 @@ The workflow runs only when started by hand, on any branch, from the Actions tab or with `gh workflow run`: [CI on GitHub's runners](../README.md) says how and lists the inputs. -Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. +Results: the job logs, the Claude job's summary (Claude's usage) and the artifacts `ios-sim-smoke-test` (a screenshot, a 1000-px view and an accessibility tree per step, the app log, the snapshots in `snapshots/`), `ios-sim-backend-logs` and `ios-sim-claude-test` (the Claude job's smoke test in `smoke-test/`, and per session: `prompt.md`, `report.md`, `shots/`, `transcript.jsonl`, `usage.json`). Read them with `gh run view --log` and `gh run download -n `. ## How long it takes, what it costs - `plan` (ubuntu-24.04, 10 s) reads what the run asks for, names the core library and the backend after their sources, `hashFiles` over the Haskell sources, `cabal.project`, `simplex-chat.cabal` and the build script, and reuses an artifact of the same name that an earlier run of the same branch uploaded (30 days). So every run builds the core library, and the backend when asked, from the branch's own Haskell code, and rebuilds only when those sources change. - `core` (macOS, 30–45 min when not reused): `build-core.sh` builds `lib:simplex-chat` with cabal and the iOS flags, as the Nix iOS build does, into the package `scripts/ios/prepare.sh` expects, and `prepare-libs.sh` marks it for the simulator (`mac2ios`). - `backend` (macOS, 20–45 min when not reused, only when asked): `build-backend.sh` builds `simplex-chat`, `simplex-directory-service` and `smp-server`. -- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the backend start about 10 min when asked. +- `app` (macOS): the Xcode build 8–22 min, the smoke test about 10 min, the snapshots about 18 min more, the backend start about 10 min when asked. - `claude` (macOS, only when asked, after `app` passed): a new simulator with the app job's build and the smoke test, about 7 min, then the sessions (the smoke plan's took 2.6 min). A run with reused builds and no extras takes about 25 minutes plus the wait for a macOS runner. Hosted minutes are free for this public repository; the limit is the repository's five concurrent macOS jobs, shared with PR builds (each `build.yml` run takes two), so queue waits can be long. The workflow saves no Actions caches, which this repository evicts within hours and shares with `build.yml`: builds are reused only as the artifacts above, and a core or backend rebuild compiles its dependencies too. @@ -22,7 +22,7 @@ The macOS jobs run on `macos-26`, the image with Xcode 26 and the iPhone 17 simu ## The Claude session -It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept a day) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. +It runs in its own job, `claude`, once the app job has passed: the job installs that job's build (the artifact `ios-sim-app`, kept 14 days) in a new simulator, runs the smoke test to reach the sessions' starting state, then one session per layout listed in `claude-scope`, with Claude Code installed from npm at the version the desktop workflow pins too. Only this job references the environment `app-sim-claude`, which holds the secret `CLAUDE_CODE_OAUTH_TOKEN` (a Claude subscription token) and admits the branches its rule allows; one runs per branch at a time. Only the Claude step gets the token, and `claude-test.sh` passes it to the `claude` processes alone. The job checks out without persisting its GitHub token and installs a pinned AXe (`install-axe.sh`), since it holds the Claude token. It starts the test backend when `claude-scope` says `backend: yes`; a run that asks for Claude must then ask for the backend too, or the plan job fails. On master it says `no`, since the smoke plan doesn't use the backend. Each session's final message ends with `CLAUDE-TEST: PASS`, or `CLAUDE-TEST: FAIL` and the failed cases; the step fails unless every session passes. @@ -36,7 +36,23 @@ Haiku 5.5 drives the app well: the smoke plan takes a few dozen turns and cents; ## Driving the app: `ui` -`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. +`ui` wraps AXe (`install-axe.sh`) for scripts and for Claude: tap by label, value or point, long-press, swipe, type, paste, keys, wait for text, find, screenshots with trees, switch the layout (one-hand UI or top bar), relaunch, text size, and the backend's directory (`ui` with no arguments lists them). It needs `SIM_UDID` and `UI_OUT`, which the smoke test exports to the job. Lessons kept in its comments and in the smoke test: text fields have no label (tap them by value), the keyboard capitalizes and autocorrects (compare typed text case-insensitively), the closed user picker is always in the tree, "Cancel" clears the search field and its "Close" image doesn't, the keyboard's keys are below the screen. + +## Snapshots: `snapshots.sh` + +At the end of the app job's smoke test, `smoke-test.sh` runs `snapshots.sh /snapshots` when the app it tested lies in the derived data of a build (`…/Build/Products/Debug-iphonesimulator`); the Claude job, which installs the app from a tarball, skips it. + +`snapshots.sh` builds the unit-test target `SimpleX Snapshots` (`apps/ios/SimpleX Snapshots/SnapshotTests.swift`, hosted by the app) with `xcodebuild build-for-testing`, then runs it in the smoke test's simulator three times, after `xcrun simctl ui content_size` `large`, `extra-extra-extra-large` and `accessibility-medium`, and sets `large` back. The test hosts each preview listed in `previewScenes()` (the `previews` of a `PreviewProvider`, in a `VStack`) in a `UIHostingController` in a window of the screen's width and of the larger of two heights, the preview's ideal height and its height in a frame of the screen's size, in the light and dark themes without a wallpaper, ends text editing and removes the running animations, so a text cursor or a spinner is drawn the same in every run, and draws it with `drawHierarchy` at scale 1, one pixel per point. The chat item previews (`allSizes`) are rendered at the three sizes, the other previews at `large` only. + +`previewScenes()` lists every `PreviewProvider` in `apps/ios` except those in `EXCLUDED` of `apps/ios/SimpleX Snapshots/check-previews.py`, whose renders differ between runs and can be made identical only by a change of the app's code. The iOS job of `build.yml` runs `check-previews.py`, which fails when a `PreviewProvider` is neither listed nor excluded, or when a file has a `#Preview`, since the test renders previews by their type. + +The test runs in UTC (`NSTimeZone.default`) and en_US (`-testLanguage en -testRegion US`), so the renders do not depend on the runner's time zone or locale; a preview that shows the current date or time differs between runs, so the previews and the sample contact use `previewDate` (`SimpleXChat/ChatTypes.swift`) in place of the current time. The keyboard autocorrects the profile name typed in the smoke test differently between runs, so the test sets the app's current user to the sample user. + +The images go to `snapshots/` of the artifact `ios-sim-smoke-test` as 8-bit sRGB PNG files without alpha (`preferredRange = .standard`, `opaque`), named `--.png`, with `index-.json` listing each file's scene, theme, size, pixel width and height, scale and the content size category the app saw. + +When `apps/ios/SimpleX Snapshots/References` exists, `snapshots.sh` then runs `scripts/ci/snapshots-compare.py `. It compares each render listed in `index-*.json` with the reference of the same name: a render passes when its file is identical to the reference, or when no channel of any pixel differs from it by more than 8 of 255. A missing or extra render, a changed size or a pixel beyond the tolerance fails: the script writes `compare.json` (each file's result and the failed files) next to the renders and, per failed file, `snapshots-diff/` with the reference, the render and the changed pixels in red side by side, lists the failed files in the job summary and exits with 1, which fails the smoke test step. + +`scripts/ci/snapshots-remote.py ios record|check [branch]` renders the snapshots of a pushed branch (the current one by default) on GitHub's runner: it starts `ios-sim.yml` on the branch, waits for the run and downloads `snapshots/` and `snapshots-diff/` of its `ios-sim-smoke-test` to a temporary folder. `record` replaces the PNG files in `apps/ios/SimpleX Snapshots/References` with the run's renders and prints the folder's `git status`, for review and commit; `check` prints the files that failed the run's comparison and the folder of their diff images, and exits with 1 when any failed. It takes the token from `GITHUB_TOKEN` or `GH_TOKEN`, otherwise from `gh auth token`; the token must be allowed to start workflows. ## The test backend diff --git a/scripts/ci/ios-sim/claude-guard.py b/scripts/ci/ios-sim/claude-guard.py index ab131ae997..c335b9836a 100755 --- a/scripts/ci/ios-sim/claude-guard.py +++ b/scripts/ci/ios-sim/claude-guard.py @@ -11,12 +11,18 @@ import os import re import sys -# nothing that chains, pipes, redirects, substitutes, expands or escapes +# nothing that chains, pipes, redirects, substitutes, expands with $ or escapes UI_COMMAND = re.compile(r"ui( [^;&|<>$`()\\\r\n]*)?") +# and, outside quotes, no glob, brace or tilde expansion, which could turn names of files the session creates into +# arguments; QUOTED finds the shell's quotes only because UI_COMMAND refuses backslashes +UNQUOTED_EXPANSION = re.compile(r"[*?\[\]{}~]") +QUOTED = re.compile(r"\"[^\"]*\"|'[^']*'") call = json.load(sys.stdin) command = (call.get("tool_input") or {}).get("command", "").strip() -allowed = call.get("tool_name") != "Bash" or UI_COMMAND.fullmatch(command) +allowed = call.get("tool_name") != "Bash" or ( + UI_COMMAND.fullmatch(command) and not UNQUOTED_EXPANSION.search(QUOTED.sub("", command)) +) if os.environ.get("CLAUDE_GUARD_LOG"): with open(os.environ["CLAUDE_GUARD_LOG"], "a") as log: log.write(f"{'allow' if allowed else 'refuse'} {call.get('tool_name')}: {command[:200]!r}\n") diff --git a/scripts/ci/ios-sim/claude-scope b/scripts/ci/ios-sim/claude-scope index 7d917dc20b..4571871501 100644 --- a/scripts/ci/ios-sim/claude-scope +++ b/scripts/ci/ios-sim/claude-scope @@ -5,6 +5,6 @@ backend: no model: claude-haiku-5-5 layouts: reachable -cases-reachable: S1-S3 -max-turns: 60 -max-budget-usd: 3 +cases-reachable: S2, S3, A1, A2, A3, A4, A5, J1, J2, A6, B1, B2, B3, J3, J4, J5, B4, B5, B6, B7 +max-turns: 350 +max-budget-usd: 5 diff --git a/scripts/ci/ios-sim/device-test-plan.md b/scripts/ci/ios-sim/device-test-plan.md index cf82b7daf0..6bfe81d9ad 100644 --- a/scripts/ci/ios-sim/device-test-plan.md +++ b/scripts/ci/ios-sim/device-test-plan.md @@ -1,9 +1,37 @@ -# Device test plan: smoke test +# Device test plan: channel message signing and channel moderators -The app as the scripted smoke test leaves it: a profile created through onboarding, without conversations. A branch that tests a feature replaces this plan with its own (same table format) and names its cases in `claude-scope`. +The app as the scripted smoke test leaves it: the profile "citester", without conversations. The cases run in the order of the tables and build on each other: one channel, "ci signing", owned by citester, and two more profiles in this app, "cimod" and "cisub", that join it by its link. Do not reorder them: cisub must join only after cimod has become a moderator (B2), so that cimod learns about cisub. + +## Setup + +- The channel uses the app's preset chat relays. Creating it and joining it wait for the relays: use `ui wait "" 120` there, and take a shot if a wait fails. +- **Sending a message:** in the chat, tap the message field at the bottom (find it in `ui tree`: the text view next to the send button; for the channel's owner its placeholder is "Broadcast"), `ui type ""`, then tap the send button at the right end of the field. Check the message then shows in the chat. +- **Channel info:** tap the channel name at the top of the chat (`ui find "ci signing"`, the element with the smallest y; tap it with `ui tap-xy`). Go back with the navigation back buttons; close a sheet by swiping it down. +- **Message menu:** `ui long-press ` on the message's text (centre from `ui find`) opens its menu ("Info", "Moderate", …). "Info" opens the message's details; close them by swiping down. +- **Signatures are read as text, never from the seal icons:** a message's "Info" shows "Signed" (your own message), "Signed & verified" (someone else's, checked) or "Signature missing". +- **Switching profile:** open the picker with your profile image (`Image "Account"`), then tap the other profile's name in the row at the top of the picker (`ui find `, then `ui tap-xy`; the picker's elements are in the tree even while it is closed, so open it first). The chat list then shows that profile's chats; open "ci signing" from it. +- **Adding a profile:** picker → "Your chat profiles" → "Add profile" → tap the field "Enter your name…" (`ui tap-value "Enter your name…"`), type the name, check the field's value in the tree (the keyboard may capitalize it: then fix it), tap "Create profile". The new profile becomes the active one; close "Your chat profiles" by swiping it down. +- **Joining by the copied link** (from a profile without conversations): "Compose" → "Scan / Paste link" → "Tap to paste link". If iOS asks to allow pasting, tap "Allow Paste". An alert names the channel with "Open channel": tap it; the channel opens with "Tap Join channel" and a "Join channel" bar at the bottom: tap the bar, and wait up to 120 s until the chat shows "you are subscriber". | ID | Do | Expect | |----|----|--------| -| S1 | Look at the screen: `ui tree`, then `ui shot S1-` | The chat list of a profile without conversations: the heading "Talk to someone", the cards "Let someone connect to you" and "Connect via link or QR code", and the toolbar with your profile image (`Image "Account"`, 32 x 32) and "Compose": at the bottom of the screen with the one-hand UI, at the top with the top bar | -| S2 | Tap "Let someone connect to you", then "Back" | The page "Create your link" with the cards "Invite someone privately" and "Create your public address" and a "Back" button; after "Back", "Talk to someone" again | -| S3 | Open the profile picker by tapping the toolbar's profile image, tap "Settings", then close Settings by swiping it down | The picker shows your profile name and "Settings"; Settings opens with "Your settings" and "Network & servers"; after closing, "Talk to someone" again | +| S2 | "Compose" → "Create public channel" → `ui tap-value "Enter channel name…"`, type `ci signing` (check the value) → tap the "Create public channel" button (the list row, not the title; use `ui tap-xy` if the label is ambiguous) → wait up to 120 s for "Channel link"; if "%d/%d relays active" stays below the total for 120 s with at least 1 active, tap "Continue" and "Continue" in the alert → on "Channel link" tap "Continue" (top right) | The channel "ci signing" opens as its chat | +| S3 | Send `before signing` | "before signing" shows in the chat as sent | +| A1 | Channel info → "Channel preferences" | The first row is the switch "Sign messages", off; its footer says "Do not require signing messages." | +| A2 | Turn "Sign messages" on | Alert "Sign messages?" with the message "Messages sent before will show "signature missing" to subscribers who join later." and the buttons "Enable" and "Cancel". Tap "Cancel": the switch is off again | +| A3 | Turn "Sign messages" on again, tap "Enable", then "Save and notify subscribers"; go back to the chat | After "Enable" the switch stays on (footer "Require signing messages."); after saving and going back, the chat shows the event "Sign messages: on" | +| A4 | Send `after signing`, then long-press it → "Info" | The message's info says "Signed" | +| A5 | Channel info → "Channel preferences" | "Chat with admins" is still off (its footer says "Prohibit chats with admins."); saving in A3 did not turn it on | +| J1 | Channel info → "Channel link" → "Share link" → in the share sheet tap "Copy" (`ui find Copy`; if it is not in the tree, `ui tap-xy` at its position in the screenshot) → close the sheet and go back to the chat list | The link is copied (the sheet closes or shows it was copied) | +| J2 | Add the profile `cimod`; as cimod, join by the copied link | The chat "ci signing" shows "you are subscriber" | +| A6 | As cimod in "ci signing": tap the message "before signing"; then long-press it → "Info"; then long-press "after signing" → "Info" | Tapping "before signing" shows the alert "Signature missing" with "The message was sent before the channel started signing messages, or it is fake."; its info says "Signature missing"; the info of "after signing" says "Signed & verified" | +| B1 | Switch to citester → "ci signing" → channel info → "Subscribers" → "cimod" → tap the "Change role" row | The role menu lists exactly "subscriber", "contributor" and "moderator" (no admin, no owner) | +| B2 | Choose "moderator" → "Change role?" → "Change"; go back to the chat | cimod's info shows the role moderator under the section header "Moderator" (any case); the chat shows "you changed role of cimod to moderator" | +| B3 | Switch to cimod → "ci signing"; send `moderator post` | The chat shows "changed your role to moderator"; "moderator post" shows as sent | +| J3 | Add the profile `cisub`; as cisub, join by the same link (it is still on the clipboard; if not, copy it again as citester as in J1) | cisub's chat "ci signing" shows "you are subscriber" | +| J4 | Switch to citester → "ci signing" → channel info → "Subscribers" → "cisub" → "Change role" → "contributor" → "Change" | cisub's role shows contributor, under the section header "Contributor" | +| J5 | Switch to cisub → "ci signing"; send `contributor post` | "contributor post" shows as sent | +| B4 | Switch to cimod → "ci signing" → channel info | The info shows "Subscribers"; its list includes "cisub" | +| B5 | Open "cisub" in that list | cisub's info offers "Block for all" (do not tap it yet) | +| B6 | Back in the chat, long-press "contributor post" → "Moderate" → "Delete member message?" → "Delete" | The message shows "moderated by cimod" in its place | +| B7 | Channel info → "Subscribers" → "cisub" → "Block for all" → "Block subscriber for all?" → "Block for all" | cisub's info now offers "Unblock for all"; the chat shows "you blocked cisub" | diff --git a/scripts/ci/ios-sim/smoke-test.sh b/scripts/ci/ios-sim/smoke-test.sh index e9e245a1ab..9df68585cb 100755 --- a/scripts/ci/ios-sim/smoke-test.sh +++ b/scripts/ci/ios-sim/smoke-test.sh @@ -2,6 +2,8 @@ # Installs the app in a new iPhone simulator and runs the scripted UI cases with the ui helper: onboarding to the # chat list with the one-hand UI (the default), then the chat list again with the top bar. It leaves the app in the one-hand UI. +# For an app in the derived data of a build, it then renders the previews of the SimpleX Snapshots tests to the snapshots +# folder of the output dir (snapshots.sh). # Usage: scripts/ci/ios-sim/smoke-test.sh # Each step saves a screenshot and an accessibility tree to the output dir and prints the tree to the log. # Later steps of the job get SIM_UDID and UI_OUT, with the simulator still running. @@ -122,3 +124,8 @@ expect_chat_list reachable expect_chat_list top "$ui" layout reachable echo "smoke test passed: onboarding reaches the chat list, which shows its onboarding pages and toolbar with the one-hand UI and with the top bar" + +products=$(dirname "$app") +if [[ "$products" == */Build/Products/Debug-iphonesimulator ]]; then + "$(dirname "$0")/snapshots.sh" "${products%/Build/Products/Debug-iphonesimulator}" "$UI_OUT/snapshots" +fi diff --git a/scripts/ci/ios-sim/snapshots.sh b/scripts/ci/ios-sim/snapshots.sh new file mode 100755 index 0000000000..13c1b1a267 --- /dev/null +++ b/scripts/ci/ios-sim/snapshots.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +derived=$1 +out=$2 +mkdir -p "$out" + +xcode=( + -project apps/ios/SimpleX.xcodeproj + -scheme "SimpleX (iOS)" + -configuration Debug + -destination "id=$SIM_UDID" + -derivedDataPath "$derived" + -onlyUsePackageVersionsFromResolvedFile + -skipPackagePluginValidation + -skipMacroValidation + -only-testing:"SimpleX Snapshots" + ARCHS=arm64 + CODE_SIGN_STYLE=Manual + CODE_SIGN_IDENTITY=- + DEVELOPMENT_TEAM= + PROVISIONING_PROFILE_SPECIFIER= +) + +xcodebuild build-for-testing -quiet "${xcode[@]}" + +for size in large extra-extra-extra-large accessibility-medium; do + xcrun simctl ui "$SIM_UDID" content_size "$size" + TEST_RUNNER_SNAPSHOT_DIR="$out" TEST_RUNNER_SNAPSHOT_SIZE="$size" xcodebuild test-without-building -testLanguage en -testRegion US "${xcode[@]}" +done +xcrun simctl ui "$SIM_UDID" content_size large + +ls -l "$out" + +references="apps/ios/SimpleX Snapshots/References" +if [ -d "$references" ]; then + python3 "$(dirname "$0")/../snapshots-compare.py" "$references" "$out" "$(dirname "$out")/snapshots-diff" +fi diff --git a/scripts/ci/ios-sim/ui b/scripts/ci/ios-sim/ui index 6738dac5c2..a705155388 100755 --- a/scripts/ci/ios-sim/ui +++ b/scripts/ci/ios-sim/ui @@ -6,6 +6,7 @@ # ui tap