core, ui: badge redeem errors and fixes (#7515)

- redeem errors are typed (CEBadgeRedeemError) instead of matched by text in the apps
- service timeout (A_SERVICE) decodes in the apps and offers Retry via the existing retry alert
- unexpected redeem errors show the error itself instead of a generic message
- service error codes are a typed enum in the apps (BadgeServiceErrorCode)
- CRBadgeRedeemed returns badge state, so the apps skip a second round-trip after redeem
- setBadgeAlertAcked is scoped to user_id
- badgeChanged updates non-active profiles, so other profiles' badges don't go stale
- pitch banner is not shown to a profile that already has a badge
- one badgeTypeName per platform, used by the badge screen and the badge info alert
- BadgeAlertKind and BadgeAlertPrice decode via standard JSON, no custom decoders
- dead "Support ended" title branch removed from Your Badge view
- kotlin: users from badge responses carry remoteHostId
- kotlin: redeem code field keeps the IME's cursor and composition state
- kotlin: "Get your code" shown in all flavours
- kotlin: "Don't show again" -> "Dismiss", matching iOS
- kotlin: parseBadgeCode moved next to its FFI in platform/Core.kt
- kotlin: BadgesView no longer cross-fades on badge state updates
- kotlin: section title not uppercased
- ios: redeem code field parses once per change
- ios: A_SERVICE rejected reason is not decoded
- CLI: "cannot redeem badge code: ..." with the source of the error
- comments clarified
This commit is contained in:
spaced4ndy
2026-09-16 14:15:11 +00:00
committed by GitHub
parent 9af6fb00e6
commit f38c7e0f2b
26 changed files with 482 additions and 434 deletions
@@ -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<BadgeAlertPrice> {
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<BadgeAlertKind> {
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 {
@@ -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<Boolean>? = null, retryNum: Int = 0): API? {
val r = sendCmd(rhId, cmd, retryNum = retryNum)
private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState<Boolean>? = 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<T>(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<BadgeServiceErrorCode> {
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
@@ -40,6 +40,13 @@ external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
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
@@ -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<String?>(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<TextFieldValue>, submitting: Boolean, applyCodeInput: (String) -> Unit) {
private fun CodeField(code: MutableState<TextFieldValue>, 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<TextFieldValue>, 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<TextFieldValue>, 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
}
@@ -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 {
@@ -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
}
@@ -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<TextFieldValue>, 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(
@@ -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(
@@ -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<Chat>): Boolean =
chats.any { chat ->
when (val c = chat.chatInfo) {
@@ -178,6 +178,7 @@
<string name="proxy_destination_error_failed_to_connect">Forwarding server %1$s failed to connect to destination server %2$s. Please try later.</string>
<string name="proxy_destination_error_broker_host">Destination server address of %1$s is incompatible with forwarding server %2$s settings.</string>
<string name="proxy_destination_error_broker_version">Destination server version of %1$s is incompatible with forwarding server %2$s.</string>
<string name="service_request_timeout_desc">The service did not respond. Please try again.</string>
<string name="please_try_later">Please try later.</string>
<string name="error_sending_message">Error sending message</string>
<string name="error_forwarding_messages">Error forwarding messages</string>
@@ -3251,11 +3252,9 @@
<string name="badges_error_code_used">This code has already been used.</string>
<string name="badges_error_code_expired">This code has expired.</string>
<string name="badges_error_rate_limited">Too many attempts. Please try again later.</string>
<string name="badges_error_service_failed">The badge service is unavailable. Please try again later.</string>
<string name="badges_error_bad_service_response">The badge service sent an unexpected response.</string>
<string name="badges_error_bad_service_response">The badge service sent an unexpected response: %1$s</string>
<string name="badges_error_credential_not_verified">This app version cannot verify this badge. Please update the app.</string>
<string name="badges_error_unsupported_version">This app version is too old for the badge service. Please update the app.</string>
<string name="badges_error_network">Connection error. Please check your network connection.</string>
<string name="badges_error_badge_ended">The code was accepted, but the badge it grants has already ended.</string>
<string name="badges_error_unknown">The code could not be redeemed.</string>
<string name="badges_code_you_scanned_is_not_badge_code">The code you scanned is not a badge code.</string>
@@ -3267,7 +3266,7 @@
<string name="badges_type_investor">Investor</string>
<string name="badges_support_ended_on">Your support ended on %1$s.</string>
<string name="badges_remind_me_later">Remind me later</string>
<string name="badges_dont_show_again">Don\'t show again</string>
<string name="badges_dismiss">Dismiss</string>
<string name="supporter_perks">Supporter perks</string>
<string name="v7_1_supporter_badge_title">Supporter badge ❤️</string>
<string name="v7_1_supporter_badge_body">Help keep the network running — send files up to 2 GB.</string>