mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-16 04:30:52 +00:00
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:
+8
-47
@@ -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 {
|
||||
|
||||
+168
-84
@@ -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
|
||||
|
||||
|
||||
+18
-45
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+3
-14
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+9
-1
@@ -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(
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user