diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 4a629bbd41..592ad2e041 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -420,7 +420,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): - return "/_badge ack \(userId) \(badgePurchaseId) \(alertKind.text) \(onOff(snooze)) \(episode)" + return "/_badge ack \(userId) \(badgePurchaseId) \(badgeAlertKindParam(alertKind)) \(onOff(snooze)) \(episode)" case .showVersion: return "/version" case let .getAgentSubsTotal(userId): return "/get subs total \(userId)" case let .getAgentServersSummary(userId): return "/get servers summary \(userId)" @@ -693,6 +693,17 @@ enum ChatCommand: ChatCmdProtocol { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } + // /_badge ack takes the kind in core's text encoding, not the JSON tag + private func badgeAlertKindParam(_ kind: BadgeAlertKind) -> String { + switch kind { + case .renewalApproaching: "renewal_approaching" + case .paymentIssue: "payment_issue" + case .subscriptionEnded: "subscription_ended" + case .prepaidEnding: "prepaid_ending" + case .supportEnded: "support_ended" + } + } + private func maybeContent(_ mc: MsgContent?) -> String { if case let .text(s) = mc, s.isEmpty { "" @@ -1035,7 +1046,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case appSettings(appSettings: AppSettings) // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored - case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool) + case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) case badgeState(user: UserRef, badgeState: BadgeState?) var responseType: String { @@ -1143,7 +1154,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .archiveExported(archiveErrors): return String(describing: archiveErrors) case let .archiveImported(archiveErrors): return String(describing: archiveErrors) case let .appSettings(appSettings): return String(describing: appSettings) - case let .badgeRedeemed(u, redeemedBadge, newBadge): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)") + case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index b90527507c..31995a9d5c 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -124,8 +124,8 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDe } // Spec: spec/api.md#chatApiSendCmdWithRetry -func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { - let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) +func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult? { + let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum, log: log) if inProgress == nil || inProgress?.boxedValue == true, case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) { return await withCheckedContinuation { cont in @@ -135,7 +135,7 @@ func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool cont.resume(returning: nil) }, onRetry: { - let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1) + let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1, log: log) cont.resume(returning: r1) } ) @@ -200,6 +200,10 @@ func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: Stri title: NSLocalizedString("No private routing session", comment: "alert title"), message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer) ) + case .errorAgent(.AGENT(.A_SERVICE(.timeout))): ( + title: NSLocalizedString("Connection timeout", comment: "alert title"), + message: NSLocalizedString("The service did not respond. Please try again.", comment: "alert message") + ) default: nil } } @@ -2182,86 +2186,35 @@ func resetAgentServersStats() async throws { try await sendCommandOkResp(.resetAgentServersStats) } -// The failures redeemBadgeCode raises, as core flattens them to command error text. -// tests/Bots/BadgeServiceTests.hs asserts that text, so a change in core breaks a test there. -enum BadgeRedeemError: Error { - case invalidCode - case serviceNotConfigured - case alreadyActive - case codeInvalid - case codeUsed - case codeExpired - case rateLimited - case serviceFailed - case badServiceResponse - case credentialNotVerified - case unsupportedVersion - case networkError - case badgeEnded - case unknown +// log: false because the code is a bearer secret until it is redeemed - it is in the command. +// nil when the user cancels the retry alert. +func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: User, newBadge: Bool, badgeState: BadgeState?)? { + let r: APIResult? = await chatApiSendCmdWithRetry(.apiRedeemBadgeCode(userId: userId, code: code), log: false) + guard let r else { return nil } + // redeemedBadge is dropped: the user's profile carries what is shown + if case let .result(.badgeRedeemed(user, _, newBadge, badgeState)) = r { return (user, newBadge, badgeState) } + throw r.unexpected } -private enum BadgeErrorText { - static let invalidCode = "invalid badge code" - static let serviceNotConfigured = "badge service not configured" - static let alreadyActive = "badge already active" - static let unknownKeyIndex = "unknown badge key index" - static let credentialNotVerified = "badge credential does not verify against configured key" - static let invalidResponse = "invalid badge service response, " - static let unexpectedResponse = "unexpected badge service response: " - static let serviceError = "badge service error: " - // raised by sendServiceRequestTo, not by redeemBadgeCode itself, when the reply is not JSON - static let undecodableResponse = "invalid service response" -} - -func badgeRedeemError(_ error: ChatError) -> BadgeRedeemError { - // the app's own classifier decides what counts as a network failure; only the classification is - // used here, not its retry policy - a client that retries makes code guessing cheaper - if retryableNetworkErrorAlert(error) != nil { return .networkError } - guard case let .error(.commandError(message)) = error else { return .unknown } - switch message { - case BadgeErrorText.invalidCode: return .invalidCode - case BadgeErrorText.serviceNotConfigured: return .serviceNotConfigured - case BadgeErrorText.alreadyActive: return .alreadyActive - case BadgeErrorText.unknownKeyIndex, BadgeErrorText.credentialNotVerified: return .credentialNotVerified - case BadgeErrorText.undecodableResponse: return .badServiceResponse - default: break - } - if message.hasPrefix(BadgeErrorText.invalidResponse) || message.hasPrefix(BadgeErrorText.unexpectedResponse) { - return .badServiceResponse - } - if message.hasPrefix(BadgeErrorText.serviceError) { - return badgeServiceError(String(message.dropFirst(BadgeErrorText.serviceError.count))) - } - return .unknown -} - -// the service's own tag, which core bounds to [a-z0-9_] and 32 characters and which is never shown -private func badgeServiceError(_ tag: String) -> BadgeRedeemError { - switch tag { - case "code_invalid": return .codeInvalid - case "code_used": return .codeUsed - case "code_expired": return .codeExpired - case "rate_limited": return .rateLimited - // retrying never succeeds: the client is too old for the service - case "unsupported_version": return .unsupportedVersion - default: return .serviceFailed - } -} - -// log: false because the code is a bearer secret until it is redeemed - it is in the command, and a -// service response echoed into an error message would carry it into the terminal with the response. -func apiRedeemBadgeCode(_ userId: Int64, _ code: String) async throws -> (user: User, newBadge: Bool) { - let r: APIResult = await chatApiSendCmd(.apiRedeemBadgeCode(userId: userId, code: code), log: false) - switch r { - // redeemedBadge is dropped: it is the credential, and the user's profile carries what is shown - case let .result(.badgeRedeemed(user, _, newBadge)): return (user, newBadge) - case let .error(e): throw badgeRedeemError(e) - default: - // the response type alone - it names a case or a JSON key, never the service's message - logger.error("apiRedeemBadgeCode: unexpected \(r.responseType)") - throw BadgeRedeemError.unknown +// localized where the user can act on it; otherwise the error itself, so a screenshot says what happened +func redeemErrorText(_ error: Error) -> String { + if case let .error(.badgeRedeemError(e)) = error as? ChatError { + switch e { + case .invalidCode: return NSLocalizedString("This code is not valid.", comment: "alert message") + case .serviceNotConfigured: return NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") + case .badgeActive: return NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") + case .serviceError(.codeInvalid): return NSLocalizedString("This code was not recognised.", comment: "alert message") + case .serviceError(.codeUsed): return NSLocalizedString("This code has already been used.", comment: "alert message") + case .serviceError(.codeExpired): return NSLocalizedString("This code has expired.", comment: "alert message") + case .serviceError(.rateLimited): return NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") + case .serviceError(.unsupportedVersion): return NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") + case .serviceError: break + 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") + } } + return String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error)) } func apiGetBadgeState(_ userId: Int64) async throws -> BadgeState? { @@ -3085,10 +3038,10 @@ func processReceivedMsg(_ res: ChatEvent) async { } } case let .badgeChanged(user, badgeState): - if active(user) { - await MainActor.run { - // read by core after retiring or presenting, so it carries the profile badge as changed - m.updateUser(user) + await MainActor.run { + // read by core after retiring or presenting, so it carries the profile badge as changed + m.updateUser(user) + if active(user) { BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) } } diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift index 6298425e8c..a9442a3ed0 100644 --- a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -123,24 +123,30 @@ struct BadgesRedeemCodeView: View { .disabled(submitting) } + // a changed field re-enters through onChange with the formatted text, which is when it is parsed private func applyCodeInput(_ s: String) { let formatted = formatBadgeCodeInput(s) - if formatted != code { code = formatted } - canonicalCode = parseBadgeCode(formatted) + if formatted != code { + code = formatted + } else { + canonicalCode = parseBadgeCode(formatted) + } } private func processQRCode(_ resp: Result) { switch resp { case let .success(r): let formatted = formatBadgeCodeInput(r.string) - if parseBadgeCode(formatted) == nil { + if let canonical = parseBadgeCode(formatted) { + // set here rather than through onChange, which runs after redeem() reads it + code = formatted + canonicalCode = canonical + redeem() + } else { showAlert( NSLocalizedString("Invalid QR code", comment: "alert title"), message: NSLocalizedString("The code you scanned is not a badge code.", comment: "alert message") ) - } else { - applyCodeInput(formatted) - redeem() } case let .failure(e): logger.error("processQRCode QR code error: \(e.localizedDescription)") @@ -167,56 +173,36 @@ struct BadgesRedeemCodeView: View { submitting = true Task { do { - let (redeemedUser, newBadge) = try await apiRedeemBadgeCode(user.userId, sending) - let badgeState = try? await apiGetBadgeState(user.userId) + guard let redeemed = try await apiRedeemBadgeCode(user.userId, sending) else { + await MainActor.run { submitting = false } + return + } await MainActor.run { submitting = false - // written before the pop: BadgesView swaps its content under this pushed view, so - // the pop reveals Your Badge already in place rather than animating it afterwards - if let badgeState { - BadgeModel.shared.set(userId: user.userId, badgeState: badgeState) - } - // the response is the only carrier: redeeming raises no event that refreshes the - // profile, so without this the badge beside the name is the one from before - chatModel.updateUser(redeemedUser) - if let badgeState, !badgeState.shown { + // set before dismissing: BadgesView then switches Support to Your Badge while this screen + // still covers it, so the pop lands on Your Badge instead of showing the switch + BadgeModel.shared.set(userId: user.userId, badgeState: redeemed.badgeState) + chatModel.updateUser(redeemed.user) + if let badgeState = redeemed.badgeState, !badgeState.shown { // a replay adds no purchase; a fresh code's badge can be retired on arrival - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(newBadge ? .badgeEnded : .codeUsed)) + let message = redeemed.newBadge + ? NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") + : NSLocalizedString("This code has already been used.", comment: "alert message") + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: message) } else { supporterBannerShown = true dismiss() } } } catch let error { - let redeemError = error as? BadgeRedeemError ?? .unknown - // the mapped case only - core embeds the service's response in some of these messages - logger.error("apiRedeemBadgeCode: \(String(describing: redeemError))") + logger.error("apiRedeemBadgeCode: \(responseError(error))") await MainActor.run { submitting = false - showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: failureMessage(redeemError)) + showAlert(NSLocalizedString("Cannot redeem code", comment: "alert title"), message: redeemErrorText(error)) } } } } - - private func failureMessage(_ failure: BadgeRedeemError) -> String { - switch failure { - case .invalidCode: NSLocalizedString("This code is not valid.", comment: "alert message") - case .serviceNotConfigured: NSLocalizedString("This app version cannot redeem badge codes.", comment: "alert message") - case .alreadyActive: NSLocalizedString("This profile already has a badge. Redeem the code on another profile, or once this badge ends.", comment: "alert message") - case .codeInvalid: NSLocalizedString("This code was not recognised.", comment: "alert message") - case .codeUsed: NSLocalizedString("This code has already been used.", comment: "alert message") - case .codeExpired: NSLocalizedString("This code has expired.", comment: "alert message") - case .rateLimited: NSLocalizedString("Too many attempts. Please try again later.", comment: "alert message") - case .serviceFailed: NSLocalizedString("The badge service is unavailable. Please try again later.", comment: "alert message") - case .badServiceResponse: NSLocalizedString("The badge service sent an unexpected response.", comment: "alert message") - case .credentialNotVerified: NSLocalizedString("This app version cannot verify this badge. Please update the app.", comment: "alert message") - case .unsupportedVersion: NSLocalizedString("This app version is too old for the badge service. Please update the app.", comment: "alert message") - case .networkError: NSLocalizedString("Connection error. Please check your network connection.", comment: "alert message") - case .badgeEnded: NSLocalizedString("The code was accepted, but the badge it grants has already ended.", comment: "alert message") - case .unknown: NSLocalizedString("The code could not be redeemed.", comment: "alert message") - } - } } struct BadgesRedeemCodeView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index ceb40e77cd..166abc449a 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -14,18 +14,10 @@ struct BadgesYourBadgeView: View { let badgeState: BadgeState var showsAsSheet: Bool = false - private var title: LocalizedStringKey { - badgeState.shown ? "Your badge" : "Support ended" - } - - // pushed, the navigation bar carries the title and animates it; as a sheet root there is no bar - // to put it in, so the title is drawn in the content, as the Support screen does - private var navTitle: LocalizedStringKey { showsAsSheet ? "" : title } - var body: some View { VStack(spacing: 0) { if showsAsSheet { - Text(title) + Text("Your badge") .font(.largeTitle) .bold() .foregroundColor(theme.colors.primary) @@ -51,7 +43,7 @@ struct BadgesYourBadgeView: View { } } .frame(maxHeight: .infinity) - .navigationTitle(navTitle) + .navigationTitle(showsAsSheet ? "" : "Your badge") .navigationBarTitleDisplayMode(showsAsSheet ? .inline : .large) .modifier(ThemedBackground(grouped: true)) } @@ -69,7 +61,7 @@ struct BadgeSummary: View { .frame(width: 68, height: 68) .padding(.bottom, 8) - badgeTypeName(badgeState.badgeType) + Text(badgeTypeName(badgeState.badgeType)) .font(.title3) .fontWeight(.semibold) @@ -81,13 +73,3 @@ struct BadgeSummary: View { .padding(.vertical, 8) } } - -// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key -private func badgeTypeName(_ t: BadgeType) -> Text { - switch t { - case .supporter: Text("Supporter") - case .legend: Text("Legend") - case .investor: Text("Investor") - case let .unknown(s): Text(verbatim: s) - } -} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index da829a92ef..61f29485a7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -382,6 +382,10 @@ struct ChatListView: View { badgeModel.alert?.kind == .supportEnded && badgeModel.userId == chatModel.currentUser?.userId } + private var hasShownBadge: Bool { + badgeModel.badgeState?.shown == true && badgeModel.userId == chatModel.currentUser?.userId + } + private func showSupportEndedDismissAlert() { showAlert(NSLocalizedString("Support ended", comment: "alert title")) { [ @@ -470,7 +474,7 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) .zIndex(1) - } else if !supporterBannerShown && chatModel.chats.count > 3 { + } else if !supporterBannerShown && !hasShownBadge && chatModel.chats.count > 3 { SupportSimpleXBanner( onTap: { showBadgesSheet = true }, onDismiss: showSupportSimpleXDismissAlert diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift index fa49adc29f..26d9821158 100644 --- a/apps/ios/Shared/Views/Helpers/NameBadge.swift +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -147,8 +147,7 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { ) default: // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title - let t = badge.badge.badgeType.text - let title = t.prefix(1).uppercased() + t.dropFirst() + let title = badgeTypeName(badge.badge.badgeType) if case .investor = badge.badge.badgeType { let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name) showAlert(title, message: message) { @@ -172,3 +171,13 @@ func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) { } } } + +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +func badgeTypeName(_ t: BadgeType) -> String { + switch t { + case .supporter: NSLocalizedString("Supporter", comment: "badge type") + case .legend: NSLocalizedString("Legend", comment: "badge type") + case .investor: NSLocalizedString("Investor", comment: "badge type") + case let .unknown(s): s + } +} diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 09609ab7fd..bc23e53011 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -163,8 +163,8 @@ public func chatJsonLength(_ s: String) -> Int { return Int(chat_json_length(&c)) } -// The code as core will accept it - prefix and 20 characters, no separators - or nil if it does not -// parse. Validity is decided only here: a second check-character implementation would drift. +// the code in core's canonical form (prefix and 20 characters, no separators), or nil when core rejects it. +// The check character is verified by core via FFI, not re-implemented here, so the two cannot disagree. public func parseBadgeCode(_ s: String) -> String? { var c = s.cString(using: .utf8)! let canonical = fromCString(chat_parse_badge_code(&c)!) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 33d683288a..d7b8623ab3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -812,6 +812,7 @@ public enum ChatErrorType: Decodable, Hashable { case agentVersion case agentNoSubResult(agentConnId: String) case commandError(message: String) + case badgeRedeemError(badgeRedeemError: BadgeRedeemError) case serverProtocol case agentCommandError(message: String) case invalidFileDescription(message: String) @@ -823,6 +824,84 @@ public enum ChatErrorType: Decodable, Hashable { case exception(message: String) } +public enum BadgeRedeemError: Decodable, Hashable { + case invalidCode + case serviceNotConfigured + case badgeActive + case serviceError(serviceError: BadgeServiceErrorCode) + case invalidResponse(message: String) + case unknownKeyIndex + case credentialNotVerified +} + +// the service is deployed ahead of clients, so a code this version does not know keeps its tag +public enum BadgeServiceErrorCode: Decodable, Hashable { + case badRequest + case unsupportedVersion + case unknownPurchaseKey + case unknownOfferId + case offerDisabled + case offerMismatch + case productUnavailable + case paymentNotEntitled + case paymentPending + case providerUnavailable + case rateLimited + case codeInvalid + case codeUsed + case codeExpired + case receiptInvalid + case receiptUsed + case internalError + case unknown(String) + + public var text: String { + switch self { + case .badRequest: "bad_request" + case .unsupportedVersion: "unsupported_version" + case .unknownPurchaseKey: "unknown_purchase_key" + case .unknownOfferId: "unknown_offer_id" + case .offerDisabled: "offer_disabled" + case .offerMismatch: "offer_mismatch" + case .productUnavailable: "product_unavailable" + case .paymentNotEntitled: "payment_not_entitled" + case .paymentPending: "payment_pending" + case .providerUnavailable: "provider_unavailable" + case .rateLimited: "rate_limited" + case .codeInvalid: "code_invalid" + case .codeUsed: "code_used" + case .codeExpired: "code_expired" + case .receiptInvalid: "receipt_invalid" + case .receiptUsed: "receipt_used" + case .internalError: "internal" + case let .unknown(s): s + } + } + + public init(from decoder: Decoder) throws { + switch try decoder.singleValueContainer().decode(String.self) { + case "bad_request": self = .badRequest + case "unsupported_version": self = .unsupportedVersion + case "unknown_purchase_key": self = .unknownPurchaseKey + case "unknown_offer_id": self = .unknownOfferId + case "offer_disabled": self = .offerDisabled + case "offer_mismatch": self = .offerMismatch + case "product_unavailable": self = .productUnavailable + case "payment_not_entitled": self = .paymentNotEntitled + case "payment_pending": self = .paymentPending + case "provider_unavailable": self = .providerUnavailable + case "rate_limited": self = .rateLimited + case "code_invalid": self = .codeInvalid + case "code_used": self = .codeUsed + case "code_expired": self = .codeExpired + case "receipt_invalid": self = .receiptInvalid + case "receipt_used": self = .receiptUsed + case "internal": self = .internalError + case let s: self = .unknown(s) + } + } +} + public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) @@ -1077,6 +1156,15 @@ public enum SMPAgentError: Decodable, Hashable { case A_CRYPTO case A_DUPLICATE case A_QUEUE(queueErr: String) + case A_SERVICE(serviceError: AgentServiceError) +} + +public enum AgentServiceError: Decodable, Hashable { + case rejected + case timeout + case noPendingRequest + case notDRAddress + case badSignature } public enum ArchiveError: Decodable, Hashable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b8f6fd0bb0..c6e3bd5d74 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -350,62 +350,17 @@ private func badgeDateText(_ date: Date) -> String { DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .none) } -public struct BadgeAlertPrice: Hashable { +public struct BadgeAlertPrice: Codable, Hashable { public var amount: Int64 public var currency: String } -extension BadgeAlertPrice: Codable { - // encoded as the Haskell tuple it comes from: [amount, currency] - public init(from decoder: Decoder) throws { - var c = try decoder.unkeyedContainer() - amount = try c.decode(Int64.self) - currency = try c.decode(String.self) - } - - public func encode(to encoder: Encoder) throws { - var c = encoder.unkeyedContainer() - try c.encode(amount) - try c.encode(currency) - } -} - -public enum BadgeAlertKind: Hashable { +public enum BadgeAlertKind: String, Codable, Hashable { case renewalApproaching case paymentIssue case subscriptionEnded case prepaidEnding case supportEnded - case unknown(String) - - public var text: String { - switch self { - case .renewalApproaching: "renewal_approaching" - case .paymentIssue: "payment_issue" - case .subscriptionEnded: "subscription_ended" - case .prepaidEnding: "prepaid_ending" - case .supportEnded: "support_ended" - case let .unknown(s): s - } - } -} - -extension BadgeAlertKind: Codable { - public init(from decoder: Decoder) throws { - switch try decoder.singleValueContainer().decode(String.self) { - case "renewal_approaching": self = .renewalApproaching - case "payment_issue": self = .paymentIssue - case "subscription_ended": self = .subscriptionEnded - case "prepaid_ending": self = .prepaidEnding - case "support_ended": self = .supportEnded - case let s: self = .unknown(s) - } - } - - public func encode(to encoder: Encoder) throws { - var c = encoder.singleValueContainer() - try c.encode(text) - } } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index f9a3c35917..56c3c34cb6 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -534,7 +534,8 @@ func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, - retryNum: Int32 = 0 + retryNum: Int32 = 0, + log: Bool = true ) async -> APIResult? ``` 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 f033c0963e..b5c0f23c60 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 @@ -2246,55 +2246,16 @@ data class BadgeAlert( val dateText: String get() = badgeDateText(date) } -@Serializable(with = BadgeAlertPriceSerializer::class) +@Serializable data class BadgeAlertPrice(val amount: Long, val currency: String) -// encoded as the Haskell tuple it comes from: [amount, currency] -object BadgeAlertPriceSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("BadgeAlertPrice") - override fun deserialize(decoder: Decoder): BadgeAlertPrice { - require(decoder is JsonDecoder) - val arr = decoder.decodeJsonElement().jsonArray - return BadgeAlertPrice(arr[0].jsonPrimitive.long, arr[1].jsonPrimitive.content) - } - override fun serialize(encoder: Encoder, value: BadgeAlertPrice) { - require(encoder is JsonEncoder) - encoder.encodeJsonElement(buildJsonArray { add(value.amount); add(value.currency) }) - } -} - -@Serializable(with = BadgeAlertKindSerializer::class) -sealed class BadgeAlertKind { - @Serializable @SerialName("renewal_approaching") object RenewalApproaching: BadgeAlertKind() - @Serializable @SerialName("payment_issue") object PaymentIssue: BadgeAlertKind() - @Serializable @SerialName("subscription_ended") object SubscriptionEnded: BadgeAlertKind() - @Serializable @SerialName("prepaid_ending") object PrepaidEnding: BadgeAlertKind() - @Serializable @SerialName("support_ended") object SupportEnded: BadgeAlertKind() - @Serializable @SerialName("unknown") data class Unknown(val kind: String): BadgeAlertKind() - - val text: String - get() = when (this) { - is RenewalApproaching -> "renewal_approaching" - is PaymentIssue -> "payment_issue" - is SubscriptionEnded -> "subscription_ended" - is PrepaidEnding -> "prepaid_ending" - is SupportEnded -> "support_ended" - is Unknown -> kind - } -} - -object BadgeAlertKindSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeAlertKind", PrimitiveKind.STRING) - override fun deserialize(decoder: Decoder): BadgeAlertKind = - when (val v = decoder.decodeString()) { - "renewal_approaching" -> BadgeAlertKind.RenewalApproaching - "payment_issue" -> BadgeAlertKind.PaymentIssue - "subscription_ended" -> BadgeAlertKind.SubscriptionEnded - "prepaid_ending" -> BadgeAlertKind.PrepaidEnding - "support_ended" -> BadgeAlertKind.SupportEnded - else -> BadgeAlertKind.Unknown(v) - } - override fun serialize(encoder: Encoder, value: BadgeAlertKind) = encoder.encodeString(value.text) +@Serializable +enum class BadgeAlertKind { + @SerialName("renewalApproaching") RenewalApproaching, + @SerialName("paymentIssue") PaymentIssue, + @SerialName("subscriptionEnded") SubscriptionEnded, + @SerialName("prepaidEnding") PrepaidEnding, + @SerialName("supportEnded") SupportEnded } private fun badgeDateText(date: Instant): String { 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 ed787cf266..8b4e4fc315 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -562,55 +562,44 @@ object ChatController { suspend fun resetAgentServersStats(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ResetAgentServersStats()) - fun badgeRedeemError(error: ChatError): BadgeRedeemError { - // the app's own classifier decides what counts as a network failure; only the classification is - // used here, not its retry policy - a client that retries makes code guessing cheaper - if (retryableNetworkErrorAlert(error) != null) return BadgeRedeemError.NetworkError - if (error !is ChatError.ChatErrorChat || error.errorType !is ChatErrorType.CommandError) return BadgeRedeemError.Unknown - val message = error.errorType.message - when (message) { - BadgeErrorText.invalidCode -> return BadgeRedeemError.InvalidCode - BadgeErrorText.serviceNotConfigured -> return BadgeRedeemError.ServiceNotConfigured - BadgeErrorText.alreadyActive -> return BadgeRedeemError.AlreadyActive - BadgeErrorText.unknownKeyIndex, BadgeErrorText.credentialNotVerified -> return BadgeRedeemError.CredentialNotVerified - BadgeErrorText.undecodableResponse -> return BadgeRedeemError.BadServiceResponse - } - if (message.startsWith(BadgeErrorText.invalidResponse) || message.startsWith(BadgeErrorText.unexpectedResponse)) { - return BadgeRedeemError.BadServiceResponse - } - if (message.startsWith(BadgeErrorText.serviceError)) { - return badgeServiceError(message.removePrefix(BadgeErrorText.serviceError)) - } - return BadgeRedeemError.Unknown - } - - // the service's own tag, which core bounds to [a-z0-9_] and 32 characters and which is never shown - private fun badgeServiceError(tag: String): BadgeRedeemError = when (tag) { - "code_invalid" -> BadgeRedeemError.CodeInvalid - "code_used" -> BadgeRedeemError.CodeUsed - "code_expired" -> BadgeRedeemError.CodeExpired - "rate_limited" -> BadgeRedeemError.RateLimited - // retrying never succeeds: the client is too old for the service - "unsupported_version" -> BadgeRedeemError.UnsupportedVersion - else -> BadgeRedeemError.ServiceFailed - } - - // log = false because the code is a bearer secret until it is redeemed - it is in the command, and a - // service response echoed into an error message would carry it into the terminal with the response. - suspend fun apiRedeemBadgeCode(rh: Long?, userId: Long, code: String): BadgeRedeemResult { - val r = sendCmd(rh, CC.ApiRedeemBadgeCode(userId, code), log = false) + // log = false because the code is a bearer secret until it is redeemed - it is in the command. + // null when the user cancels the retry alert. + suspend fun apiRedeemBadgeCode(rh: Long?, userId: Long, code: String): BadgeRedeemResult? { + val r = sendCmdWithRetry(rh, CC.ApiRedeemBadgeCode(userId, code), log = false) ?: return null return when { - // redeemedBadge is dropped: it is the credential, and the user's profile carries what is shown - r is API.Result && r.res is CR.BadgeRedeemed -> BadgeRedeemResult.Redeemed(r.res.user, r.res.newBadge) - r is API.Error -> BadgeRedeemResult.Failed(badgeRedeemError(r.err)) + // redeemedBadge is dropped: the user's profile carries what is shown + r is API.Result && r.res is CR.BadgeRedeemed -> BadgeRedeemResult.Redeemed(r.res.user.updateRemoteHostId(rh), r.res.newBadge, r.res.badgeState) + r is API.Error -> BadgeRedeemResult.Failed(r.err) else -> { // the response type alone - it names a case or a JSON key, never the service's message Log.e(TAG, "apiRedeemBadgeCode: unexpected ${r.responseType}") - BadgeRedeemResult.Failed(BadgeRedeemError.Unknown) + BadgeRedeemResult.Failed(null) } } } + // localized where the user can act on it; otherwise the error itself, so a screenshot says what happened + fun redeemErrorText(err: ChatError?): String { + if (err is ChatError.ChatErrorChat && err.errorType is ChatErrorType.CEBadgeRedeemError) { + when (val e = err.errorType.badgeRedeemError) { + is BadgeRedeemError.InvalidCode -> return generalGetString(MR.strings.badges_error_invalid_code) + is BadgeRedeemError.ServiceNotConfigured -> return generalGetString(MR.strings.badges_error_service_not_configured) + is BadgeRedeemError.BadgeActive -> return generalGetString(MR.strings.badges_error_already_active) + is BadgeRedeemError.ServiceError -> when (e.serviceError) { + is BadgeServiceErrorCode.CodeInvalid -> return generalGetString(MR.strings.badges_error_code_invalid) + is BadgeServiceErrorCode.CodeUsed -> return generalGetString(MR.strings.badges_error_code_used) + is BadgeServiceErrorCode.CodeExpired -> return generalGetString(MR.strings.badges_error_code_expired) + is BadgeServiceErrorCode.RateLimited -> return generalGetString(MR.strings.badges_error_rate_limited) + is BadgeServiceErrorCode.UnsupportedVersion -> return generalGetString(MR.strings.badges_error_unsupported_version) + else -> {} + } + 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) + } + } + return "${generalGetString(MR.strings.error_prefix)}: ${err?.string ?: generalGetString(MR.strings.badges_error_unknown)}" + } + suspend fun apiGetBadgeState(rh: Long?, userId: Long): BadgeState? { val r = sendCmd(rh, CC.ApiGetBadgeState(userId)) if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState @@ -835,8 +824,8 @@ object ChatController { } } - private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0): API? { - val r = sendCmd(rhId, cmd, retryNum = retryNum) + private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0, log: Boolean = true): API? { + val r = sendCmd(rhId, cmd, retryNum = retryNum, log = log) val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null if ((inProgress == null || inProgress.value) && alert != null) { return suspendCancellableCoroutine { cont -> @@ -856,7 +845,7 @@ object ChatController { safeResume( runCatching { coroutineScope { - sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1) + sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1, log = log) } } ) @@ -919,6 +908,10 @@ object ChatController { else -> null } } + is AgentErrorType.AGENT -> + if (e.agentErr is SMPAgentError.A_SERVICE && e.agentErr.serviceError is AgentServiceError.Timeout) { + return MR.strings.connection_timeout to generalGetString(MR.strings.service_request_timeout_desc) + } else -> return null } return null @@ -3565,11 +3558,13 @@ object ChatController { } } is CR.BadgeChanged -> - if (active(r.user)) { + if (rhId == chatModel.remoteHostId()) { withContext(Dispatchers.Main) { // read by core after retiring or presenting, so it carries the profile badge as changed - chatModel.updateUser(r.user) - BadgeModel.set(rhId, r.user.userId, r.badgeState) + chatModel.updateUser(r.user.updateRemoteHostId(rhId)) + if (active(r.user)) { + BadgeModel.set(rhId, r.user.userId, r.badgeState) + } } } is CR.BadgeAlertR -> @@ -3875,41 +3870,10 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } } -// The failures redeemBadgeCode raises, as core flattens them to command error text. -// tests/Bots/BadgeServiceTests.hs asserts that text, so a change in core breaks a test there. -enum class BadgeRedeemError { - InvalidCode, - ServiceNotConfigured, - AlreadyActive, - CodeInvalid, - CodeUsed, - CodeExpired, - RateLimited, - ServiceFailed, - BadServiceResponse, - CredentialNotVerified, - UnsupportedVersion, - NetworkError, - BadgeEnded, - Unknown -} - -private object BadgeErrorText { - const val invalidCode = "invalid badge code" - const val serviceNotConfigured = "badge service not configured" - const val alreadyActive = "badge already active" - const val unknownKeyIndex = "unknown badge key index" - const val credentialNotVerified = "badge credential does not verify against configured key" - const val invalidResponse = "invalid badge service response, " - const val unexpectedResponse = "unexpected badge service response: " - const val serviceError = "badge service error: " - // raised by sendServiceRequestTo, not by redeemBadgeCode itself, when the reply is not JSON - const val undecodableResponse = "invalid service response" -} - sealed class BadgeRedeemResult { - class Redeemed(val user: User, val newBadge: Boolean): BadgeRedeemResult() - class Failed(val error: BadgeRedeemError): BadgeRedeemResult() + class Redeemed(val user: User, val newBadge: Boolean, val badgeState: BadgeState?): BadgeRedeemResult() + // err is null for a response of an unexpected type, which is logged where it is received + class Failed(val err: ChatError?): BadgeRedeemResult() } // ChatCommand @@ -4317,7 +4281,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" is ApiGetBadgeState -> "/_badge state $userId" - is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${alertKind.text} ${onOff(snooze)} $episode" + is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" is GetAgentSubsTotal -> "/get subs total $userId" @@ -4559,6 +4523,15 @@ sealed class CC { fun onOff(b: Boolean): String = if (b) "on" else "off" +// /_badge ack takes the kind in core's text encoding, not the JSON tag +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" +} + @Serializable data class NewUser( val profile: Profile?, @@ -6879,7 +6852,7 @@ sealed class CR { @Serializable @SerialName("agentServersSummary") class AgentServersSummary(val user: UserRef, val serversSummary: PresentedServersSummary): CR() // badges // the full user, not UserRef: its profile carries the badge that setUserBadge just stored - @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean): CR() + @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() @@ -7276,7 +7249,7 @@ sealed class CR { is ArchiveExported -> "${archiveErrors.map { it.string } }" is ArchiveImported -> "${archiveErrors.map { it.string } }" is AppSettingsR -> json.encodeToString(appSettings) - is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge") + is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) @@ -7331,6 +7304,97 @@ sealed class SimplexDomainError { @Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError() } +@Serializable +sealed class BadgeRedeemError { + val string: String get() = when (this) { + is InvalidCode -> "invalidCode" + is ServiceNotConfigured -> "serviceNotConfigured" + is BadgeActive -> "badgeActive" + is ServiceError -> "serviceError ${serviceError.text}" + is InvalidResponse -> "invalidResponse $message" + is UnknownKeyIndex -> "unknownKeyIndex" + is CredentialNotVerified -> "credentialNotVerified" + } + @Serializable @SerialName("invalidCode") object InvalidCode : BadgeRedeemError() + @Serializable @SerialName("serviceNotConfigured") object ServiceNotConfigured : BadgeRedeemError() + @Serializable @SerialName("badgeActive") object BadgeActive : 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() + @Serializable @SerialName("credentialNotVerified") object CredentialNotVerified : BadgeRedeemError() +} + +// the service is deployed ahead of clients, so a code this version does not know keeps its tag +@Serializable(with = BadgeServiceErrorCodeSerializer::class) +sealed class BadgeServiceErrorCode { + object BadRequest: BadgeServiceErrorCode() + object UnsupportedVersion: BadgeServiceErrorCode() + object UnknownPurchaseKey: BadgeServiceErrorCode() + object UnknownOfferId: BadgeServiceErrorCode() + object OfferDisabled: BadgeServiceErrorCode() + object OfferMismatch: BadgeServiceErrorCode() + object ProductUnavailable: BadgeServiceErrorCode() + object PaymentNotEntitled: BadgeServiceErrorCode() + object PaymentPending: BadgeServiceErrorCode() + object ProviderUnavailable: BadgeServiceErrorCode() + object RateLimited: BadgeServiceErrorCode() + object CodeInvalid: BadgeServiceErrorCode() + object CodeUsed: BadgeServiceErrorCode() + object CodeExpired: BadgeServiceErrorCode() + object ReceiptInvalid: BadgeServiceErrorCode() + object ReceiptUsed: BadgeServiceErrorCode() + object Internal: BadgeServiceErrorCode() + data class Unknown(val code: String): BadgeServiceErrorCode() + + val text: String + get() = when (this) { + is BadRequest -> "bad_request" + is UnsupportedVersion -> "unsupported_version" + is UnknownPurchaseKey -> "unknown_purchase_key" + is UnknownOfferId -> "unknown_offer_id" + is OfferDisabled -> "offer_disabled" + is OfferMismatch -> "offer_mismatch" + is ProductUnavailable -> "product_unavailable" + is PaymentNotEntitled -> "payment_not_entitled" + is PaymentPending -> "payment_pending" + is ProviderUnavailable -> "provider_unavailable" + is RateLimited -> "rate_limited" + is CodeInvalid -> "code_invalid" + is CodeUsed -> "code_used" + is CodeExpired -> "code_expired" + is ReceiptInvalid -> "receipt_invalid" + is ReceiptUsed -> "receipt_used" + is Internal -> "internal" + is Unknown -> code + } +} + +object BadgeServiceErrorCodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeServiceErrorCode", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): BadgeServiceErrorCode = + when (val v = decoder.decodeString()) { + "bad_request" -> BadgeServiceErrorCode.BadRequest + "unsupported_version" -> BadgeServiceErrorCode.UnsupportedVersion + "unknown_purchase_key" -> BadgeServiceErrorCode.UnknownPurchaseKey + "unknown_offer_id" -> BadgeServiceErrorCode.UnknownOfferId + "offer_disabled" -> BadgeServiceErrorCode.OfferDisabled + "offer_mismatch" -> BadgeServiceErrorCode.OfferMismatch + "product_unavailable" -> BadgeServiceErrorCode.ProductUnavailable + "payment_not_entitled" -> BadgeServiceErrorCode.PaymentNotEntitled + "payment_pending" -> BadgeServiceErrorCode.PaymentPending + "provider_unavailable" -> BadgeServiceErrorCode.ProviderUnavailable + "rate_limited" -> BadgeServiceErrorCode.RateLimited + "code_invalid" -> BadgeServiceErrorCode.CodeInvalid + "code_used" -> BadgeServiceErrorCode.CodeUsed + "code_expired" -> BadgeServiceErrorCode.CodeExpired + "receipt_invalid" -> BadgeServiceErrorCode.ReceiptInvalid + "receipt_used" -> BadgeServiceErrorCode.ReceiptUsed + "internal" -> BadgeServiceErrorCode.Internal + else -> BadgeServiceErrorCode.Unknown(v) + } + override fun serialize(encoder: Encoder, value: BadgeServiceErrorCode) = encoder.encodeString(value.text) +} + data class ConnectionPlanResult( val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo?, @@ -7736,6 +7800,7 @@ sealed class ChatErrorType { is AgentVersion -> "agentVersion" is AgentNoSubResult -> "agentNoSubResult" is CommandError -> "commandError $message" + is CEBadgeRedeemError -> "badgeRedeemError ${badgeRedeemError.string}" is ServerProtocol -> "serverProtocol" is AgentCommandError -> "agentCommandError" is InvalidFileDescription -> "invalidFileDescription" @@ -7820,6 +7885,7 @@ sealed class ChatErrorType { @Serializable @SerialName("agentVersion") object AgentVersion: ChatErrorType() @Serializable @SerialName("agentNoSubResult") class AgentNoSubResult(val agentConnId: String): ChatErrorType() @Serializable @SerialName("commandError") class CommandError(val message: String): ChatErrorType() + @Serializable @SerialName("badgeRedeemError") class CEBadgeRedeemError(val badgeRedeemError: BadgeRedeemError): ChatErrorType() @Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType() @Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType() @Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType() @@ -8254,6 +8320,7 @@ sealed class SMPAgentError { is A_CRYPTO -> "A_CRYPTO" is A_DUPLICATE -> "A_DUPLICATE" is A_QUEUE -> "A_QUEUE" + is A_SERVICE -> "A_SERVICE ${serviceError.string}" } @Serializable @SerialName("A_MESSAGE") object A_MESSAGE: SMPAgentError() @Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED(val prohibitedErr: String): SMPAgentError() @@ -8262,6 +8329,23 @@ sealed class SMPAgentError { @Serializable @SerialName("A_CRYPTO") object A_CRYPTO: SMPAgentError() @Serializable @SerialName("A_DUPLICATE") object A_DUPLICATE: SMPAgentError() @Serializable @SerialName("A_QUEUE") class A_QUEUE(val queueErr: String): SMPAgentError() + @Serializable @SerialName("A_SERVICE") class A_SERVICE(val serviceError: AgentServiceError): SMPAgentError() +} + +@Serializable +sealed class AgentServiceError { + val string: String get() = when (this) { + is Rejected -> "rejected" + is Timeout -> "timeout" + is NoPendingRequest -> "noPendingRequest" + is NotDRAddress -> "notDRAddress" + is BadSignature -> "badSignature" + } + @Serializable @SerialName("rejected") object Rejected: AgentServiceError() + @Serializable @SerialName("timeout") object Timeout: AgentServiceError() + @Serializable @SerialName("noPendingRequest") object NoPendingRequest: AgentServiceError() + @Serializable @SerialName("notDRAddress") object NotDRAddress: AgentServiceError() + @Serializable @SerialName("badSignature") object BadSignature: AgentServiceError() } @Serializable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 6b4e3e1353..ad0e9aaa5a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -40,6 +40,13 @@ external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String +// the code in core's canonical form (prefix and 20 characters, no separators), or null when core rejects it. +// The check character is verified by core via FFI, not re-implemented here, so the two cannot disagree. +fun parseBadgeCode(s: String): String? { + val canonical = chatParseBadgeCode(s) + return if (canonical.isEmpty()) null else canonical +} + val chatModel: ChatModel get() = chatController.chatModel diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt index 6462215051..337fc5db6d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -39,13 +38,6 @@ private const val badgeCodePrefix = "SB" private const val badgeCodeBodyLength = 20 private const val badgeCodeGroupLength = 5 -// The code as core will accept it - prefix and 20 characters, no separators - or null if it does not -// parse. Validity is decided only here: a second check-character implementation would drift. -fun parseBadgeCode(s: String): String? { - val canonical = chatParseBadgeCode(s) - return if (canonical.isEmpty()) null else canonical -} - // Regroups what was typed; validity and the folding of ambiguous characters are core's alone. private fun formatBadgeCodeInput(s: String): String { val normalized = StringBuilder() @@ -73,9 +65,11 @@ fun BadgesRedeemCodeView() { val canonicalCode = remember { mutableStateOf(null) } val submitting = remember { mutableStateOf(false) } - fun applyCodeInput(s: String) { - val formatted = formatBadgeCodeInput(s) - if (formatted != code.value.text) code.value = TextFieldValue(formatted, selection = TextRange(formatted.length)) + // when the text is unchanged, the field's own value is kept: it carries the cursor position and the + // keyboard's composition state, which BasicTextField loses unless they are passed back to it + fun applyCodeInput(v: TextFieldValue) { + val formatted = formatBadgeCodeInput(v.text) + code.value = if (formatted != v.text) TextFieldValue(formatted, selection = TextRange(formatted.length)) else v canonicalCode.value = parseBadgeCode(formatted) } @@ -85,23 +79,20 @@ fun BadgesRedeemCodeView() { submitting.value = true withBGApi { when (val result = chatModel.controller.apiRedeemBadgeCode(rhId, user.userId, sending)) { + null -> withContext(Dispatchers.Main) { submitting.value = false } is BadgeRedeemResult.Redeemed -> { - val badgeState = try { chatModel.controller.apiGetBadgeState(rhId, user.userId) } catch (e: Exception) { null } + val badgeState = result.badgeState withContext(Dispatchers.Main) { submitting.value = false - // written before the pop: BadgesView swaps its content under this pushed view, so - // the pop reveals Your Badge already in place rather than animating it afterwards - if (badgeState != null) { - BadgeModel.set(rhId, user.userId, badgeState) - } - // the response is the only carrier: redeeming raises no event that refreshes the - // profile, so without this the badge beside the name is the one from before + // set before dismissing: BadgesView then switches Support to Your Badge while this screen + // still covers it, so the pop lands on Your Badge instead of showing the switch + BadgeModel.set(rhId, user.userId, badgeState) chatModel.updateUser(result.user) if (badgeState != null && !badgeState.shown) { // a replay adds no purchase; a fresh code's badge can be retired on arrival AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.badges_error_title), - text = generalGetString(failureMessage(if (result.newBadge) BadgeRedeemError.BadgeEnded else BadgeRedeemError.CodeUsed)) + text = generalGetString(if (result.newBadge) MR.strings.badges_error_badge_ended else MR.strings.badges_error_code_used) ) } else { supporterBannerShown.set(true) @@ -110,13 +101,12 @@ fun BadgesRedeemCodeView() { } } is BadgeRedeemResult.Failed -> { - // the mapped case only - core embeds the service's response in some of these messages - Log.e(TAG, "apiRedeemBadgeCode: ${result.error}") + Log.e(TAG, "apiRedeemBadgeCode: ${result.err?.string}") withContext(Dispatchers.Main) { submitting.value = false AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.badges_error_title), - text = generalGetString(failureMessage(result.error)) + text = chatModel.controller.redeemErrorText(result.err) ) } } @@ -163,7 +153,7 @@ fun BadgesRedeemCodeView() { false } else -> { - applyCodeInput(formatted) + applyCodeInput(TextFieldValue(formatted, selection = TextRange(formatted.length))) redeem() true } @@ -181,7 +171,7 @@ fun BadgesRedeemCodeView() { } @Composable -private fun CodeField(code: MutableState, submitting: Boolean, applyCodeInput: (String) -> Unit) { +private fun CodeField(code: MutableState, submitting: Boolean, applyCodeInput: (TextFieldValue) -> Unit) { val colors = TextFieldDefaults.textFieldColors( backgroundColor = MaterialTheme.appColors.sentMessage, textColor = MaterialTheme.colors.onBackground, @@ -190,7 +180,7 @@ private fun CodeField(code: MutableState, submitting: Boolean, a ) BasicTextField( value = code.value, - onValueChange = { applyCodeInput(it.text) }, + onValueChange = applyCodeInput, enabled = !submitting, singleLine = true, textStyle = TextStyle.Default.copy( @@ -218,10 +208,10 @@ private fun CodeField(code: MutableState, submitting: Boolean, a } @Composable -private fun PasteButton(submitting: Boolean, applyCodeInput: (String) -> Unit) { +private fun PasteButton(submitting: Boolean, applyCodeInput: (TextFieldValue) -> Unit) { val clipboard = LocalClipboardManager.current TextButton( - onClick = { clipboard.getText()?.text?.let { applyCodeInput(it) } }, + onClick = { clipboard.getText()?.text?.let { applyCodeInput(TextFieldValue(it, selection = TextRange(it.length))) } }, enabled = !submitting ) { Text(stringResource(MR.strings.paste_button), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) @@ -238,20 +228,3 @@ private fun SubmitButton(enabled: Boolean, onClick: () -> Unit) { onclick = onClick ) } - -private fun failureMessage(failure: BadgeRedeemError): StringResource = when (failure) { - BadgeRedeemError.InvalidCode -> MR.strings.badges_error_invalid_code - BadgeRedeemError.ServiceNotConfigured -> MR.strings.badges_error_service_not_configured - BadgeRedeemError.AlreadyActive -> MR.strings.badges_error_already_active - BadgeRedeemError.CodeInvalid -> MR.strings.badges_error_code_invalid - BadgeRedeemError.CodeUsed -> MR.strings.badges_error_code_used - BadgeRedeemError.CodeExpired -> MR.strings.badges_error_code_expired - BadgeRedeemError.RateLimited -> MR.strings.badges_error_rate_limited - BadgeRedeemError.ServiceFailed -> MR.strings.badges_error_service_failed - BadgeRedeemError.BadServiceResponse -> MR.strings.badges_error_bad_service_response - BadgeRedeemError.CredentialNotVerified -> MR.strings.badges_error_credential_not_verified - BadgeRedeemError.UnsupportedVersion -> MR.strings.badges_error_unsupported_version - BadgeRedeemError.NetworkError -> MR.strings.badges_error_network - BadgeRedeemError.BadgeEnded -> MR.strings.badges_error_badge_ended - BadgeRedeemError.Unknown -> MR.strings.badges_error_unknown -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt index ac64c20d7c..a144f75d1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesView.kt @@ -15,7 +15,7 @@ fun BadgesView() { if (badgeState != null && badgeState.shown) badgeState else null } - AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }) { badgeState -> + AnimatedContent(targetState = shownBadge, transitionSpec = { fadeIn() with fadeOut() }, contentKey = { it != null }) { badgeState -> if (badgeState != null) { BadgesYourBadgeView(badgeState) } else { 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 4ff8f2c6de..f752ef7e29 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 @@ -16,25 +16,23 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.BadgeState -import chat.simplex.common.model.BadgeType import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.badgeImage +import chat.simplex.common.views.helpers.badgeTypeName import chat.simplex.res.MR @Composable fun BadgesYourBadgeView(badgeState: BadgeState) { - val title = if (badgeState.shown) MR.strings.badges_your_badge else MR.strings.badges_support_ended - ColumnWithScrollBar { - AppBarTitle(stringResource(title)) + AppBarTitle(stringResource(MR.strings.badges_your_badge)) SectionView { BadgeSummary(badgeState) } SectionSpacer() - SectionView(stringResource(MR.strings.badges_ends).uppercase()) { + SectionView(stringResource(MR.strings.badges_ends)) { Text(badgeState.paidThroughText, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 12.dp)) } SectionTextFooter(stringResource(MR.strings.badges_prepaid_footer)) @@ -65,12 +63,3 @@ fun BadgeSummary(badgeState: BadgeState) { ) } } - -// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key -@Composable -private fun badgeTypeName(t: BadgeType): String = when (t) { - is BadgeType.Supporter -> stringResource(MR.strings.badges_level_supporter) - is BadgeType.Legend -> stringResource(MR.strings.badges_level_legend) - is BadgeType.Investor -> stringResource(MR.strings.badges_type_investor) - is BadgeType.Unknown -> t.type -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 82e8a71bf2..3fcf2febda 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 @@ -79,7 +79,7 @@ private fun showSupportEndedDismissAlert() { AlertManager.shared.hideAlert() withBGApi { chatModel.controller.ackBadgeAlert(snooze = false) } }) { - Text(stringResource(MR.strings.badges_dont_show_again), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_dismiss), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } SectionItemView({ AlertManager.shared.hideAlert() @@ -1051,7 +1051,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ) } } - } else if (!supporterBannerShown.value && chatModel.chats.value.size > 3) { + } else if (!supporterBannerShown.value && !hasShownBadge() && chatModel.chats.value.size > 3) { item { Box(Modifier.zIndex(1f).padding(16.dp)) { SupportSimpleXBanner( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index dcdea4147f..fcae2e6b47 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -207,9 +207,17 @@ private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() } } +// verbatim for an unknown type: it is the service's string, and must not be looked up as a localised key +fun badgeTypeName(t: BadgeType): String = when (t) { + is BadgeType.Supporter -> generalGetString(MR.strings.badges_level_supporter) + is BadgeType.Legend -> generalGetString(MR.strings.badges_level_legend) + is BadgeType.Investor -> generalGetString(MR.strings.badges_type_investor) + is BadgeType.Unknown -> t.type +} + fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) { // a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title - val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() } + val title = badgeTypeName(badge.badge.badgeType) when { badge.status == BadgeStatus.Failed -> AlertManager.shared.showAlertMsg( 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 9f4b213cb2..bd33bde96a 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 @@ -54,6 +54,9 @@ fun shouldShowOnboarding(): Boolean { fun supportEnded(): Boolean = BadgeModel.alert.value?.kind == BadgeAlertKind.SupportEnded && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) +fun hasShownBadge(): Boolean = + BadgeModel.badgeState.value?.shown == true && BadgeModel.isCurrent(chatModel.remoteHostId(), chatModel.currentUser.value?.userId) + fun hasConversations(chats: List): Boolean = chats.any { chat -> when (val c = chat.chatInfo) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 480e448954..f5fc7f8278 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -178,6 +178,7 @@ Forwarding server %1$s failed to connect to destination server %2$s. Please try later. Destination server address of %1$s is incompatible with forwarding server %2$s settings. Destination server version of %1$s is incompatible with forwarding server %2$s. + The service did not respond. Please try again. Please try later. Error sending message Error forwarding messages @@ -3251,11 +3252,9 @@ This code has already been used. This code has expired. Too many attempts. Please try again later. - The badge service is unavailable. Please try again later. - The badge service sent an unexpected response. + The badge service sent an unexpected response: %1$s This app version cannot verify this badge. Please update the app. This app version is too old for the badge service. Please update the app. - Connection error. Please check your network connection. The code was accepted, but the badge it grants has already ended. The code could not be redeemed. The code you scanned is not a badge code. @@ -3267,7 +3266,7 @@ Investor Your support ended on %1$s. Remind me later - Don\'t show again + Dismiss Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index a0ee90b83c..4c81cb50ac 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -24,6 +24,7 @@ module Simplex.Chat.Badges.Types BadgeCharge (..), BadgeIssuance (..), BadgeAlert (..), + BadgeAlertPrice (..), BadgeState (..), ) where @@ -204,7 +205,13 @@ data BadgeAlert = BadgeAlert { kind :: BadgeAlertKind, episode :: Text, date :: UTCTime, - price :: Maybe (Int64, Text) + price :: Maybe BadgeAlertPrice + } + deriving (Show) + +data BadgeAlertPrice = BadgeAlertPrice + { amount :: Int64, + currency :: Text } deriving (Show) @@ -259,12 +266,9 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "BIS") ''BadgeItemStatus) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) -instance ToJSON BadgeAlertKind where - toJSON = textToJSON - toEncoding = textToEncoding +$(JQ.deriveJSON (enumJSON $ dropPrefix "BA") ''BadgeAlertKind) -instance FromJSON BadgeAlertKind where - parseJSON = textParseJSON "BadgeAlertKind" +$(JQ.deriveJSON defaultJSON ''BadgeAlertPrice) $(JQ.deriveJSON defaultJSON ''BadgeAlert) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1e7b413aba..6206e8ae50 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,6 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode) import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -866,7 +867,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} - | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool} + | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} @@ -1476,6 +1477,16 @@ data SimplexDomainError | SDEUnknownDomain -- the resolved link's profile has no name, or a different name deriving (Eq, Show) +data BadgeRedeemError + = BREInvalidCode -- format or check character + | BREServiceNotConfigured + | BREBadgeActive + | BREServiceError {serviceError :: BadgeServiceErrorCode} + | BREInvalidResponse {message :: String} + | BREUnknownKeyIndex + | BRECredentialNotVerified + deriving (Eq, Show) + data ChatErrorType = CENoActiveUser | CENoConnectionUser {agentConnId :: AgentConnId} @@ -1548,6 +1559,7 @@ data ChatErrorType | CEAgentVersion | CEAgentNoSubResult {agentConnId :: AgentConnId} | CECommandError {message :: String} + | CEBadgeRedeemError {badgeRedeemError :: BadgeRedeemError} | CEServerProtocol {serverProtocol :: AProtocolType} | CEAgentCommandError {message :: String} | CEInvalidFileDescription {message :: String} @@ -1832,6 +1844,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "SDE") ''SimplexDomainError) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BRE") ''BadgeRedeemError) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6ba042bd94..c3d5624cb1 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3570,7 +3570,7 @@ processChatCommand cxt nm = \case APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do now <- badgeNow let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing - withStore' $ \db -> setBadgeAlertAcked db badgePurchaseId alertKind episode snoozeUntil + withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil -- after the write, so the pass it signals arms a wake for the snooze rather than raising again lift $ startBadgeWork user CRBadgeState user <$> getUserBadgeState user @@ -5220,8 +5220,8 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg -- A terminal answer drops the stash; a timeout keeps it. redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse redeemBadgeCode nm user@User {userId} codeText = do - code <- maybe (throwCmdError "invalid badge code") pure $ parseBadgeCode codeText - sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwCmdError "badge service not configured") pure + code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText + sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure g <- asks random now <- liftIO getCurrentTime let codeSent = badgeCodeText code @@ -5232,24 +5232,23 @@ redeemBadgeCode nm user@User {userId} codeText = do -- a code already redeemed here is allowed through: re-sending it returns the badge it bought -- and adds nothing. Refused before its keys are stashed and before the request, so it stays unspent replaying <- maybe (pure False) (\r -> withStore' $ \db -> isJust <$> getCodeBadgePurchase db r) redemption_ - unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwCmdError "badge already active" + unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive redemption@BadgeCodeRedemption {purchaseKey, purchasePrivKey, masterKey} <- maybe (withStore' $ \db -> createBadgeCodeRedemption db g user codeSent now) pure redemption_ let req = BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request = BSCRedeemBadgeCode {masterKey, code = codeSent}} - respData <- sendServiceRequestTo nm user sendTarget Nothing (Just purchasePrivKey) req + respBytes <- sendServiceRequestBytes nm user sendTarget Nothing (Just purchasePrivKey) req + respData <- either (const $ throwRedeemError $ BREInvalidResponse "not JSON") pure $ J.eitherDecodeStrict' respBytes case J.fromJSON (J.Object respData) of - J.Error e -> throwCmdError $ "invalid badge service response, " <> show e <> ": " <> respJSON respData + J.Error _ -> throwRedeemError $ BREInvalidResponse "not a badge service response" J.Success BSPError {code = errCode} -> do when (terminalCodeError errCode) $ withStore' $ \db -> deleteBadgeCodeRedemption db (redemptionId redemption) - throwCmdError $ "badge service error: " <> T.unpack (badgeServiceErrorText errCode) + throwRedeemError $ BREServiceError errCode J.Success BSPBadgeCredential {credential = Just cred, statement} -> storeRedeemedBadge user redemption cred statement - J.Success _ -> throwCmdError $ "unexpected badge service response: " <> respJSON respData + J.Success _ -> throwRedeemError $ BREInvalidResponse "unexpected response type" -- outside the badge lock: the chat lock must not be taken under it mapM_ presentUserBadgeToContacts present_ pure redeemed where - -- re-encoded, not shown as received: JSON escapes the control characters a terminal acts on - respJSON = LB.unpack . J.encode -- the code will never work, so the keys stashed for it are dead; a timeout keeps them terminalCodeError = \case BSECodeInvalid -> True @@ -5257,6 +5256,9 @@ redeemBadgeCode nm user@User {userId} codeText = do BSECodeExpired -> True _ -> False +throwRedeemError :: BadgeRedeemError -> CM a +throwRedeemError = throwChatError . CEBadgeRedeemError + -- | An unknown code is reported, since the service is deployed ahead of clients, but its text is -- the service's - so it is bounded and stripped before reaching a terminal that acts on controls. badgeServiceErrorText :: BadgeServiceErrorCode -> Text @@ -5584,11 +5586,11 @@ stopBadgeWorkers workers = storeRedeemedBadge :: User -> BadgeCodeRedemption -> BadgeCredential -> BadgeStatement -> CM (Maybe User, ChatResponse) storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} cred@(BadgeCredential _ credMasterKey _ info@BadgeInfo {badgeType}) statement = verifyOwnBadge cred >>= \case - Nothing -> throwCmdError "redeemed badge credential names an unknown badge key index" - Just False -> throwCmdError "redeemed badge credential does not verify against configured key" + Nothing -> throwRedeemError BREUnknownKeyIndex + Just False -> throwRedeemError BRECredentialNotVerified -- verifyCredential checks the signature against the key inside the credential, not the one we -- sent - so a credential over any other master key also verifies - Just True | credMasterKey /= masterKey -> throwCmdError "redeemed badge credential is for a different master key" + Just True | credMasterKey /= masterKey -> throwRedeemError $ BREInvalidResponse "credential is for a different master key" Just True -> do g <- asks random now <- badgeNow @@ -5603,7 +5605,8 @@ storeRedeemedBadge user@User {userId} redemption@BadgeCodeRedemption {masterKey} unless applied $ eToView $ ChatError $ CEInternalError "redeemed badge credential has no ledger row to store it against" -- nothing is due yet, but a pass is what arms the next wake, and this is the first purchase lift $ startBadgeWork user' - pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge) + badgeState <- getUserBadgeState user' + pure (if newBadge then Just user' else Nothing, CRBadgeRedeemed user' badge newBadge badgeState) -- | Store the statement's rows, then the credential against the badge debit row among them. -- 'False' when that row cannot be found, which the caller reports rather than drop in silence. @@ -5627,10 +5630,14 @@ applyBadgeStatement db g purchaseId badgeType BadgeStatement {entries} cred_ now ids -> Just (last ids) sendServiceRequestTo :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM J.Object -sendServiceRequestTo nm user sendTarget requestTimeout signKey request = do +sendServiceRequestTo nm user sendTarget requestTimeout signKey request = + sendServiceRequestBytes nm user sendTarget requestTimeout signKey request + >>= either (const $ throwCmdError "invalid service response") pure . J.eitherDecodeStrict' + +sendServiceRequestBytes :: J.ToJSON a => NetworkRequestMode -> User -> ConnectTarget 'CMContact -> Maybe NominalDiffTime -> Maybe C.PrivateKeyEd25519 -> a -> CM ByteString +sendServiceRequestBytes nm user sendTarget requestTimeout signKey request = do cReq <- resolveServiceTarget sendTarget - respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout signKey (LB.toStrict $ J.encode request) - either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData + withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq requestTimeout signKey (LB.toStrict $ J.encode request) where resolveServiceTarget = \case CTFullContact cReq -> pure cReq diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 7da9c5fd72..c17bd5f360 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -258,12 +258,12 @@ userHasBadge db User {userId} = -- | An ack and a snooze both record the occurrence answered; a snooze also records how long it -- holds, so that it silences that occurrence and not whichever one is derived next. -setBadgeAlertAcked :: DB.Connection -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO () -setBadgeAlertAcked db badgePurchaseId kind episode snoozeUntil = +setBadgeAlertAcked :: DB.Connection -> User -> Int64 -> BadgeAlertKind -> Text -> Maybe UTCTime -> IO () +setBadgeAlertAcked db User {userId} badgePurchaseId kind episode snoozeUntil = DB.execute db - "UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ?" - (kind, episode, snoozeUntil, badgePurchaseId) + "UPDATE badge_purchases SET alert_acked_kind = ?, alert_acked_episode = ?, alert_snooze_until = ? WHERE badge_purchase_id = ? AND user_id = ?" + (kind, episode, snoozeUntil, badgePurchaseId, userId) -- | Stop showing a badge that has expired unrenewed; the profile update is broadcast by the caller. clearShownBadge :: DB.Connection -> User -> Int64 -> IO () diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6c20e1b33a..007dc8af0b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -41,7 +41,7 @@ import Numeric (showFFloat) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Help -import Simplex.Chat.Library.Commands (maxImageSize) +import Simplex.Chat.Library.Commands (badgeServiceErrorText, maxImageSize) import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..)) @@ -190,7 +190,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be - CRBadgeRedeemed u badge newBadge -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] + CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRBadgeState u st -> ttyUser u $ viewUserBadgeState st CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView @@ -2841,6 +2841,16 @@ viewChatError isCmd logLevel testView = \case CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId] CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"] CECommandError e -> ["bad chat command: " <> plain e] + CEBadgeRedeemError e -> + let reason = case e of + BREInvalidCode -> "invalid code" + BREServiceNotConfigured -> "badge service not configured" + BREBadgeActive -> "badge already active" + BREServiceError code -> "badge service error: " <> T.unpack (badgeServiceErrorText code) + BREInvalidResponse m -> "invalid service response: " <> m + BREUnknownKeyIndex -> "credential names an unknown badge key index" + BRECredentialNotVerified -> "credential does not verify against configured key" + in ["cannot redeem badge code: " <> plain reason] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 3725741d37..640d0ba533 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -257,10 +257,10 @@ testRedeemUnknownCode ps = g <- C.newRandom unknown <- randomBadgeCode g alice ##> ("/_redeem_badge_code 1 " <> codeArg unknown) - alice <## "bad chat command: badge service error: code_invalid" + alice <## "cannot redeem badge code: badge service error: code_invalid" -- a failed check character is refused before anything leaves the device alice ##> "/_redeem_badge_code 1 SB-00000-00000-00000-00001" - alice <## "bad chat command: invalid badge code" + alice <## "cannot redeem badge code: invalid code" -- sent straight to the service, past the client's own check, the two are one answer (_, redeemPriv) <- atomically $ C.generateKeyPair g :: IO (C.KeyPair 'C.Ed25519) redeemDirect alice bsLink redeemPriv (T.unpack $ badgeCodeText unknown) @@ -315,7 +315,7 @@ testRedeemSecondCode ps = alice <## "supporter badge - active" alice <##. "expires " alice ##> ("/_redeem_badge_code 1 " <> codeArg legend) - alice <## "bad chat command: badge already active" + alice <## "cannot redeem badge code: badge already active" alice ##> "/p" showActiveUser alice "alice (Alice, * supporter)" alice ##> "/create user alisa" @@ -339,7 +339,7 @@ testRedeemSameCodeOtherProfile ps = alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> ("/_redeem_badge_code 2 " <> codeArg code) - alice <## "bad chat command: badge service error: code_used" + alice <## "cannot redeem badge code: badge service error: code_used" alice ##> "/p" showActiveUser alice "alisa" alice ##> "/user alice"