From 32566c493e9ebad8b18a0e4ac08a60c202ed6791 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sat, 18 Jul 2026 14:02:33 -0300 Subject: [PATCH 01/20] WIP save point: Pro wire-format app adaptation (does NOT compile yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary save point — mid-refactor, intentionally not compiling. Will be rebased/rewritten once libsession pins the response-parser structs (FINAL PIN 1f308535 moves response parsing into libsession; the kotlinx response schemas below get deleted in favour of consuming libsession structs). Durable (keep): - Proof/ProProofInfo: gen_index_hash -> revocationTag, expiry ms -> seconds; protobuf accessor setRevocationTag; user-profile get/setProAccessExpiry. - Revocation reshape: InstantAsSecondsSerializer; ProDatabase schema (revocation_tag/effective_ts/retain_until_ts, isRevoked >= effective_ts, retain_for aging) + lokiV61 migration; RevocationListPollingWorker caller. - AddProPaymentApi request build: provider slug + Google token|order_id payment_id. Superseded (to delete when libsession response structs land): - GetProDetailsApi ProDetails/Item kotlinx schema, GetProRevocationsApi schema, ProProof response serializer. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../messages/ProfileUpdateHandler.kt | 6 +- .../sending_receiving/MessageSender.kt | 2 +- .../InstantAsSecondsSerializer.kt | 29 ++++++++ .../securesms/database/RecipientRepository.kt | 12 ++-- .../database/helpers/SQLCipherOpenHelper.java | 9 ++- .../database/model/RecipientSettings.kt | 4 +- .../securesms/pro/FetchProDetailsWorker.kt | 6 +- .../securesms/pro/ProProofGenerationWorker.kt | 2 +- .../thoughtcrime/securesms/pro/ProProofs.kt | 4 +- .../securesms/pro/ProStatusManager.kt | 10 +-- .../pro/RevocationListPollingWorker.kt | 4 +- .../securesms/pro/api/AddProPaymentApi.kt | 6 +- .../securesms/pro/api/GetProDetailsApi.kt | 70 +++++++------------ .../securesms/pro/api/GetProRevocationsApi.kt | 18 +++-- .../securesms/pro/db/ProDatabase.kt | 56 ++++++++++----- 15 files changed, 140 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondsSerializer.kt diff --git a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt index 75dc0fab54..3cea2a6012 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt @@ -249,10 +249,10 @@ class ProfileUpdateHandler @Inject constructor( if (pro?.status == ProProof.STATUS_VALID && pro.proof != null && - pro.proof!!.expiryMs > nowMills) { + pro.proof!!.expirySeconds > nowMills / 1000) { proProofInfo = Conversation.ProProofInfo( - genIndexHash = pro.proof!!.genIndexHashHex.hexToByteArray(), - expiryMs = pro.proof!!.expiryMs, + revocationTag = pro.proof!!.revocationTagHex.hexToByteArray(), + expirySeconds = pro.proof!!.expirySeconds, ) proFeatures = pro.proProfileFeatures } else { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 0614aff292..7d294e4b9c 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -139,7 +139,7 @@ class MessageSender @Inject constructor( // Attach pro proof val proProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof - if (proProof != null && proProof.expiryMs > snodeClock.currentTimeMillis()) { + if (proProof != null && proProof.expirySeconds > snodeClock.currentTimeMillis() / 1000) { builder.proMessageBuilder.proofBuilder.copyFromLibSession(proProof) } else { // If we don't have any valid pro proof, clear the pro message diff --git a/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondsSerializer.kt b/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondsSerializer.kt new file mode 100644 index 0000000000..9f167cc97f --- /dev/null +++ b/app/src/main/java/org/session/libsession/utilities/serializable/InstantAsSecondsSerializer.kt @@ -0,0 +1,29 @@ +package org.session.libsession.utilities.serializable + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant + +/** + * Serializes and deserializes [java.time.Instant] as a long representing whole seconds since epoch + * in UTC. Used for the Pro wire `_ts` fields (integer seconds). + */ +class InstantAsSecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("org.session.InstantSeconds", PrimitiveKind.LONG) + + override fun serialize( + encoder: Encoder, + value: Instant + ) { + encoder.encodeLong(value.epochSecond) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.ofEpochSecond(decoder.decodeLong()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 4313fdf1cc..04c06766dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -463,7 +463,7 @@ class RecipientRepository @Inject constructor( // Safety: Let's filter again for the flow logic to be 100% sure we are only setting timers for valid proofs val validProDataList = proDataContext?.proDataList?.filter { - !it.isExpired(now) && !proDatabase.isRevoked(it.genIndexHash, snodeClock.get().currentTime()) + !it.isExpired(now) && !proDatabase.isRevoked(it.revocationTag, snodeClock.get().currentTime()) } if (changeSources != null) { @@ -505,7 +505,7 @@ class RecipientRepository @Inject constructor( // 1. Filter invalid proofs proDataList?.removeAll { - it.isExpired(now) || proDatabase.isRevoked(it.genIndexHash, snodeClock.get().currentTime()) + it.isExpired(now) || proDatabase.isRevoked(it.revocationTag, snodeClock.get().currentTime()) } // 2. Determine base Pro Data from valid proofs or ProStatusManager @@ -687,7 +687,7 @@ class RecipientRepository @Inject constructor( ProProfileFeature.PRO_BADGE ), expiry = Instant.now().plusSeconds(3600), - genIndexHash = "a1b2c3d4", + revocationTag = "a1b2c3d4", ) ) } else if (pro != null) { @@ -696,8 +696,8 @@ class RecipientRepository @Inject constructor( showProBadge = configs.userProfile.getProFeatures().contains( ProProfileFeature.PRO_BADGE ), - expiry = Instant.ofEpochMilli(pro.proProof.expiryMs), - genIndexHash = pro.proProof.genIndexHashHex, + expiry = Instant.ofEpochSecond(pro.proProof.expirySeconds), + revocationTag = pro.proProof.revocationTagHex, ) ) } @@ -724,7 +724,7 @@ class RecipientRepository @Inject constructor( RecipientSettings.ProData( showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE), expiry = convo.proProofInfo!!.expiry, - genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(), + revocationTag = convo.proProofInfo!!.revocationTag.data.toHexString(), ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index dab87c2ca5..ce53ca37ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -110,9 +110,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV58 = 79; private static final int lokiV59 = 80; private static final int lokiV60 = 81; + private static final int lokiV61 = 82; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV60; + private static final int DATABASE_VERSION = lokiV61; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -286,6 +287,8 @@ public void onCreate(SQLiteDatabase db) { SmsDatabase.addOutgoingColumn(db); MmsDatabase.Companion.addOutgoingColumn(db); + + ProDatabase.Companion.reshapeRevocationsForSeconds(db); } @Override @@ -649,6 +652,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { MmsDatabase.Companion.addOutgoingColumn(db); } + if (oldVersion < lokiV61) { + ProDatabase.Companion.reshapeRevocationsForSeconds(db); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt index a0780bd64f..1caa310be3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/RecipientSettings.kt @@ -26,7 +26,7 @@ data class RecipientSettings( data class ProData( @Serializable(with = InstantAsMillisSerializer::class) val expiry: Instant, - val genIndexHash: String, + val revocationTag: String, val showProBadge: Boolean, ) { @@ -35,7 +35,7 @@ data class RecipientSettings( features: BitSet, ): this( expiry = info.expiry, - genIndexHash = info.genIndexHash.data.toHexString(), + revocationTag = info.revocationTag.data.toHexString(), showProBadge = features.contains(ProProfileFeature.PRO_BADGE), ) fun isExpired(now: Instant): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 6efe79e888..7193a007f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -85,7 +85,7 @@ class FetchProDetailsWorker @AssistedInject constructor( configFactory.withMutableUserConfigs { configs -> if (details.expiry != null) { - configs.userProfile.setProAccessExpiryMs(details.expiry.toEpochMilli()) + configs.userProfile.setProAccessExpiry(details.expiry.epochSecond) } else { configs.userProfile.removeProAccessExpiry() } @@ -123,13 +123,13 @@ class FetchProDetailsWorker @AssistedInject constructor( } else { val currentProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof - if (currentProof == null || currentProof.expiryMs <= now) { + if (currentProof == null || currentProof.expirySeconds * 1000L <= now) { Log.d( TAG, "Pro is active but no valid proof found, scheduling proof generation now" ) ProProofGenerationWorker.schedule(context) - } else if (currentProof.expiryMs - now <= Duration.ofMinutes(60).toMillis() && + } else if (currentProof.expirySeconds * 1000L - now <= Duration.ofMinutes(60).toMillis() && details.expiry!!.toEpochMilli() - now > Duration.ofMinutes(60).toMillis() && details.autoRenewing == true ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index e81346c957..09f24dca26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -84,7 +84,7 @@ class ProProofGenerationWorker @AssistedInject constructor( } - Log.d(WORK_NAME, "Successfully generated a new pro proof expiring at ${Instant.ofEpochMilli(proof.expiryMs)}") + Log.d(WORK_NAME, "Successfully generated a new pro proof expiring at ${Instant.ofEpochSecond(proof.expirySeconds)}") Result.success() } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt index b2fa47abf0..9014dad6a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt @@ -10,7 +10,7 @@ import org.session.protos.SessionProtos fun SessionProtos.ProProof.Builder.copyFromLibSession( proProof: ProProof ): SessionProtos.ProProof.Builder = setVersion(proProof.version) - .setExpiryUnixTs(proProof.expiryMs) - .setGenIndexHash(ByteString.copyFrom(proProof.genIndexHashHex.hexToByteArray())) + .setExpiryUnixTs(proProof.expirySeconds) + .setRevocationTag(ByteString.copyFrom(proProof.revocationTagHex.hexToByteArray())) .setRotatingPublicKey(ByteString.copyFrom(proProof.rotatingPubKeyHex.hexToByteArray())) .setSig(ByteString.copyFrom(proProof.signatureHex.hexToByteArray())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index cab441e9ea..64d15dd046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -284,7 +284,7 @@ class ProStatusManager @Inject constructor( .asSequence() .filterIsInstance() .filter { convo -> - convo.proProofInfo?.genIndexHash?.let { proDatabase.isRevoked(it.data.toHexString(), snodeClock.currentTime()) } == true + convo.proProofInfo?.revocationTag?.let { proDatabase.isRevoked(it.data.toHexString(), snodeClock.currentTime()) } == true } .onEach { convo -> convo.proProofInfo = null @@ -320,7 +320,7 @@ class ProStatusManager @Inject constructor( .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) .map { configFactory.get().withUserConfigs { configs -> - configs.userProfile.getProAccessExpiryMs() + configs.userProfile.getProAccessExpiry() } } .distinctUntilChanged() @@ -341,7 +341,7 @@ class ProStatusManager @Inject constructor( .filterNotNull() .distinctUntilChanged() .mapLatest { proConfig -> - val expiry = Instant.ofEpochMilli(proConfig.proProof.expiryMs) + val expiry = Instant.ofEpochSecond(proConfig.proProof.expirySeconds) // Schedule a refresh for a random number between 10 and 60 minutes before proof expiry val refreshTime = @@ -373,7 +373,7 @@ class ProStatusManager @Inject constructor( combine( configFactory.get() .watchUserProConfig() - .mapNotNull { it?.proProof?.genIndexHashHex }, + .mapNotNull { it?.proProof?.revocationTagHex }, proDatabase.revocationChangeNotification .onStart { emit(Unit) }, @@ -385,7 +385,7 @@ class ProStatusManager @Inject constructor( .filterNotNull() .collectLatest { revokedHash -> configFactory.get().withMutableUserConfigs { configs -> - if (configs.userProfile.getProConfig()?.proProof?.genIndexHashHex == revokedHash) { + if (configs.userProfile.getProConfig()?.proProof?.revocationTagHex == revokedHash) { Log.w( DebugLogGroup.PRO_SUBSCRIPTION.label, "Current Pro proof has been revoked, clearing Pro config" diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index ae288be303..472bcaf14f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -53,7 +53,9 @@ class RevocationListPollingWorker @AssistedInject constructor( ).successOrThrow() proDatabase.updateRevocations( data = response.items, - newTicket = response.ticket + newTicket = response.ticket, + retainForSeconds = response.retainForSeconds, + now = snodeClock.currentTime() ) proDatabase.pruneRevocations(snodeClock.currentTime()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt index f1770b7005..745ac10115 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt @@ -23,9 +23,9 @@ class AddProPaymentApi @AssistedInject constructor( version = 0, masterPrivateKey = masterPrivateKey, rotatingPrivateKey = rotatingPrivateKey, - paymentProvider = BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY, - paymentId = googlePaymentToken, - orderId = googleOrderId, + providerCode = BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY, + // Google composite payment_id = "|" (backend splits on first '|'). + paymentId = "$googlePaymentToken|$googleOrderId", ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt index 0296872dbf..0682ec6f06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt @@ -7,9 +7,8 @@ import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.PaymentProvider import org.session.libsession.network.SnodeClock -import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import org.session.libsession.utilities.serializable.InstantAsSecondsSerializer import java.time.Instant class GetProDetailsApi @AssistedInject constructor( @@ -24,7 +23,7 @@ class GetProDetailsApi @AssistedInject constructor( return BackendRequests.buildGetProDetailsRequestJson( version = 0, proMasterPrivateKey = masterPrivateKey, - nowMs = snodeClock.currentTimeMillis(), + nowSeconds = snodeClock.currentTimeMillis() / 1000, count = 10, ) } @@ -41,7 +40,6 @@ class GetProDetailsApi @AssistedInject constructor( } typealias ServerProDetailsStatus = Int -typealias ServerPlanDuration = Int @Serializable class ProDetails( @@ -50,12 +48,12 @@ class ProDetails( @SerialName("auto_renewing") val autoRenewing: Boolean? = null, - @SerialName("expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("expiry_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val expiry: Instant? = null, - @SerialName("grace_period_duration_ms") - val graceDurationMs: Long? = null, + @SerialName("grace_period_duration") + val graceDuration: Long? = null, @SerialName("error_report") val errorReport: Int? = null, @@ -66,8 +64,8 @@ class ProDetails( @SerialName("items") val paymentItems: List = emptyList(), - @SerialName("refund_requested_unix_ts_ms") - val refundRequestedAtMs: Long = 0, + @SerialName("refund_requested_ts") + val refundRequestedAtSeconds: Long = 0, @@ -81,59 +79,45 @@ class ProDetails( @Serializable data class Item( @SerialName("plan") - val planDuration: ServerPlanDuration, + val plan: String, // period code, e.g. "1m" / "3m" / "1y" - val status: Int, // Payment status [Redeemed, Revoked, Expired] - we do not use this status in the clients + // Payment status code [unredeemed, redeemed, expired, revoked] - we do not use it in the clients + val status: String? = null, @SerialName("payment_provider") - val paymentProvider: PaymentProvider, + val paymentProvider: String, // opaque provider slug, e.g. "google_play" / "app_store" - @SerialName("expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("expiry_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val expiry: Instant? = null, - @SerialName("grace_period_duration_ms") - val graceDurationMs: Long? = null, + @SerialName("grace_period_duration") + val graceDuration: Long? = null, - @SerialName("platform_refund_expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("platform_refund_expiry_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val platformExpiry: Instant? = null, - @SerialName("redeemed_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("redeemed_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val timeRedeemed: Instant? = null, - @SerialName("unredeemed_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("unredeemed_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val timeUnredeemed: Instant? = null, - @SerialName("revoked_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) + @SerialName("revoked_ts") + @Serializable(with = InstantAsSecondsSerializer::class) val timeRevoked: Instant? = null, - @SerialName("google_order_id") - val googleOrderId: String? = null, - - @SerialName("google_payment_token") - val googlePaymentToken: String? = null, - - @SerialName("apple_original_tx_id") - val appleOriginalTxId: String? = null, - - @SerialName("apple_tx_id") - val appleTxId: String? = null, - - @SerialName("apple_web_line_order_id") - val appleWebLineOrderId: String? = null, + // Opaque per-provider payment id (Google: "|"; others: their id verbatim) + @SerialName("payment_id") + val paymentId: String? = null, ) companion object { const val DETAILS_STATUS_NEVER_BEEN_PRO: ServerProDetailsStatus = 0 const val DETAILS_STATUS_ACTIVE: ServerProDetailsStatus = 1 const val DETAILS_STATUS_EXPIRED: ServerProDetailsStatus = 2 - - const val SERVER_PLAN_DURATION_1_MONTH: ServerPlanDuration = 1 - const val SERVER_PLAN_DURATION_3_MONTH: ServerPlanDuration = 2 - const val SERVER_PLAN_DURATION_12_MONTH: ServerPlanDuration = 3 } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt index 2197304e41..b3071c414e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import org.session.libsession.utilities.serializable.InstantAsSecondsSerializer import java.time.Instant import kotlin.time.Duration @@ -43,20 +43,18 @@ class GetProRevocationApi @AssistedInject constructor( class ProRevocations( val ticket: Long, val items: List, - @SerialName("retry_in_s") + @SerialName("retry_in") val retryInSeconds: Long, + @SerialName("retain_for") + val retainForSeconds: Long, ) { @Serializable class Item( - @Serializable(with = InstantAsMillisSerializer::class) - @SerialName("expiry_unix_ts_ms") - val expiry: Instant, - - @Serializable(with = InstantAsMillisSerializer::class) - @SerialName("effective_unix_ts_ms") + @Serializable(with = InstantAsSecondsSerializer::class) + @SerialName("effective_ts") val effectiveFrom: Instant, - @SerialName("gen_index_hash") - val genIndexHash: String, + @SerialName("revocation_tag") + val revocationTag: String, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 52a44c1867..0c07086032 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -48,25 +48,29 @@ class ProDatabase @Inject constructor( fun updateRevocations( newTicket: Long, + retainForSeconds: Long, + now: Instant, data: List ) { var changes = 0 + // Memory-/storage-only local aging: keep each item until `seen + retain_for` (§4). + val retainUntil = now.epochSecond + retainForSeconds writableDatabase.transaction { if (data.isNotEmpty()) { //language=roomsql compileStatement( """ - INSERT INTO pro_revocations (gen_index_hash, expiry_ms, effective_from_ms) + INSERT INTO pro_revocations (revocation_tag, effective_ts, retain_until_ts) VALUES (?, ?, ?) - ON CONFLICT DO UPDATE SET expiry_ms=excluded.expiry_ms, effective_from_ms=excluded.effective_from_ms - WHERE expiry_ms != excluded.expiry_ms OR effective_from_ms != excluded.effective_from_ms + ON CONFLICT DO UPDATE SET effective_ts=excluded.effective_ts, retain_until_ts=excluded.retain_until_ts + WHERE effective_ts != excluded.effective_ts OR retain_until_ts != excluded.retain_until_ts """ ).use { stmt -> for (item in data) { - stmt.bindString(1, item.genIndexHash) - stmt.bindLong(2, item.expiry.toEpochMilli()) - stmt.bindLong(3, item.effectiveFrom.toEpochMilli()) + stmt.bindString(1, item.revocationTag) + stmt.bindLong(2, item.effectiveFrom.epochSecond) + stmt.bindLong(3, retainUntil) changes += stmt.executeUpdateDelete() stmt.clearBindings() } @@ -84,7 +88,7 @@ class ProDatabase @Inject constructor( } for (item in data) { - cache.put(item.genIndexHash, Unit) + cache.put(item.revocationTag, Unit) } if (changes > 0) { @@ -96,34 +100,36 @@ class ProDatabase @Inject constructor( //language=roomsql val pruned = writableDatabase.rawQuery(""" DELETE FROM pro_revocations - WHERE expiry_ms < ? - RETURNING gen_index_hash - """, now.toEpochMilli()).use { cursor -> + WHERE retain_until_ts < ? + RETURNING revocation_tag + """, now.epochSecond).use { cursor -> cursor.asSequence() .map { it.getString(0) } .toList() } - for (genIndexHash in pruned) { - cache.remove(genIndexHash) + for (revocationTag in pruned) { + cache.remove(revocationTag) } Log.d(TAG, "Pruned ${pruned.size} expired pro revocations") } - fun isRevoked(genIndexHash: String, now: Instant): Boolean { - if (cache[genIndexHash] != null) { + fun isRevoked(revocationTag: String, now: Instant): Boolean { + if (cache[revocationTag] != null) { return true } + // A tag is revoked once the client clock has reached its effective_ts (§4); tag-match alone + // is not enough. Local aging (retain_until_ts) is handled separately by pruneRevocations. //language=roomsql readableDatabase.query(""" SELECT 1 FROM pro_revocations - WHERE gen_index_hash = ?1 AND ?2 >= effective_from_ms AND ?2 < expiry_ms + WHERE revocation_tag = ?1 AND ?2 >= effective_ts LIMIT 1 - """, arrayOf(genIndexHash, now.toEpochMilli())).use { cursor -> + """, arrayOf(revocationTag, now.epochSecond)).use { cursor -> if (cursor.moveToFirst()) { - cache.put(genIndexHash, Unit) + cache.put(revocationTag, Unit) return true } return false @@ -219,5 +225,21 @@ class ProDatabase @Inject constructor( ADD COLUMN effective_from_ms INTEGER NOT NULL DEFAULT 0 """) } + + fun reshapeRevocationsForSeconds(db: SupportSQLiteDatabase) { + // Pro is unreleased and the revocation list is re-polled, so drop and recreate the table + // with the new seconds-based schema: revocation_tag / effective_ts / retain_until_ts + // (replaces the old gen_index_hash / expiry_ms / effective_from_ms shape). + //language=roomsql + db.execSQL("DROP TABLE IF EXISTS pro_revocations") + //language=roomsql + db.execSQL(""" + CREATE TABLE pro_revocations( + revocation_tag TEXT NOT NULL PRIMARY KEY, + effective_ts INTEGER NOT NULL, + retain_until_ts INTEGER NOT NULL + ) WITHOUT ROWID + """) + } } } \ No newline at end of file From 22e0c9f7985ca64312aac47f561ad0fab41672b0 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sat, 18 Jul 2026 21:16:46 -0300 Subject: [PATCH 02/20] WIP: rewire ProApi layer to consume libsession request/response structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save point (app does NOT compile yet — consumers/display still reference the now-deleted response schemas). - ProApi base: endpoint + buildJsonBody + kotlinx responseDeserializer -> buildProRequest(): ProRequest (endpoint+body from libsession) and parseResponse(json): Res (typed struct from libsession); check res.header. - GetProDetailsApi -> GetProDetailsResponse (parsePaymentDetailsResponse). - GenerateProProofApi / AddProPaymentApi -> ProProofResponse. - GetProRevocationApi -> GetProRevocationsResponse. Still TODO next: delete orphaned ProDetails/ProRevocations kotlinx schemas; migrate ProDataMapper/ProDatabase/ProStatusManager/FetchProDetailsWorker/ RevocationListPollingWorker to the glue structs; provider display (names from Crowdin, URLs from libsession provider_urls); .data now ProProofResponse.proof. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../securesms/pro/api/AddProPaymentApi.kt | 21 +++----- .../securesms/pro/api/GenerateProProofApi.kt | 22 +++----- .../securesms/pro/api/GetProDetailsApi.kt | 19 +++---- .../securesms/pro/api/GetProRevocationsApi.kt | 32 ++++-------- .../thoughtcrime/securesms/pro/api/ProApi.kt | 52 +++++++------------ 5 files changed, 51 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt index 745ac10115..5944189ec2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt @@ -3,9 +3,9 @@ package org.thoughtcrime.securesms.pro.api import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.serialization.DeserializationStrategy import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.ProProof +import network.loki.messenger.libsession_util.pro.ProProofResponse +import network.loki.messenger.libsession_util.pro.ProRequest import org.session.libsignal.utilities.Log class AddProPaymentApi @AssistedInject constructor( @@ -14,20 +14,18 @@ class AddProPaymentApi @AssistedInject constructor( @Assisted("master") private val masterPrivateKey: ByteArray, @Assisted private val rotatingPrivateKey: ByteArray, deps: ProApiDependencies -) : ProApi(deps) { - override val endpoint: String - get() = "add_pro_payment" - - override fun buildJsonBody(): String { - return BackendRequests.buildAddProPaymentRequestJson( - version = 0, +) : ProApi(deps) { + override fun buildProRequest(): ProRequest = + BackendRequests.buildAddProPaymentRequest( masterPrivateKey = masterPrivateKey, rotatingPrivateKey = rotatingPrivateKey, providerCode = BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY, // Google composite payment_id = "|" (backend splits on first '|'). paymentId = "$googlePaymentToken|$googleOrderId", ) - } + + override fun parseResponse(json: String): ProProofResponse = + BackendRequests.parseAddPaymentResponse(json) override fun convertErrorStatus(status: Int): AddPaymentErrorStatus { Log.w("", "AddProPayment: convertErrorStatus: $status") @@ -35,9 +33,6 @@ class AddProPaymentApi @AssistedInject constructor( ?: AddPaymentErrorStatus.GenericError } - override val responseDeserializer: DeserializationStrategy - get() = ProProof.serializer() - @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt index f243de7306..6eebdafa90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt @@ -3,9 +3,9 @@ package org.thoughtcrime.securesms.pro.api import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.serialization.DeserializationStrategy import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.ProProof +import network.loki.messenger.libsession_util.pro.ProProofResponse +import network.loki.messenger.libsession_util.pro.ProRequest import org.session.libsession.network.SnodeClock class GenerateProProofApi @AssistedInject constructor( @@ -13,23 +13,17 @@ class GenerateProProofApi @AssistedInject constructor( @Assisted private val rotatingPrivateKey: ByteArray, private val snodeClock: SnodeClock, deps: ProApiDependencies, -) : ProApi(deps) { +) : ProApi(deps) { - override val endpoint: String - get() = "generate_pro_proof" - - override fun buildJsonBody(): String { - val now = snodeClock.currentTime() - return BackendRequests.buildGenerateProProofRequestJson( - version = 0, + override fun buildProRequest(): ProRequest = + BackendRequests.buildGenerateProProofRequest( masterPrivateKey = masterPrivateKey, rotatingPrivateKey = rotatingPrivateKey, - nowMs = now.toEpochMilli(), + nowSeconds = snodeClock.currentTime().epochSecond, ) - } - override val responseDeserializer: DeserializationStrategy - get() = ProProof.serializer() + override fun parseResponse(json: String): ProProofResponse = + BackendRequests.parseProProofResponse(json) override fun convertErrorStatus(status: Int): GetProProofStatus = status diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt index 0682ec6f06..dfd3630c01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt @@ -7,6 +7,8 @@ import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.GetProDetailsResponse +import network.loki.messenger.libsession_util.pro.ProRequest import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.serializable.InstantAsSecondsSerializer import java.time.Instant @@ -15,21 +17,16 @@ class GetProDetailsApi @AssistedInject constructor( private val snodeClock: SnodeClock, @Assisted private val masterPrivateKey: ByteArray, deps: ProApiDependencies, -) : ProApi(deps) { - override val endpoint: String - get() = "get_pro_details" - - override fun buildJsonBody(): String { - return BackendRequests.buildGetProDetailsRequestJson( - version = 0, - proMasterPrivateKey = masterPrivateKey, +) : ProApi(deps) { + override fun buildProRequest(): ProRequest = + BackendRequests.buildGetProDetailsRequest( + masterPrivateKey = masterPrivateKey, nowSeconds = snodeClock.currentTimeMillis() / 1000, count = 10, ) - } - override val responseDeserializer: DeserializationStrategy - get() = ProDetails.serializer() + override fun parseResponse(json: String): GetProDetailsResponse = + BackendRequests.parsePaymentDetailsResponse(json) override fun convertErrorStatus(status: Int): Int = status diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt index b3071c414e..8760d76dff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt @@ -3,35 +3,21 @@ package org.thoughtcrime.securesms.pro.api import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import org.session.libsession.utilities.serializable.InstantAsSecondsSerializer -import java.time.Instant -import kotlin.time.Duration +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.GetProRevocationsResponse +import network.loki.messenger.libsession_util.pro.ProRequest class GetProRevocationApi @AssistedInject constructor( @Assisted private val ticket: Long?, - private val json: Json, deps: ProApiDependencies, -) : ProApi(deps) { - override val responseDeserializer: DeserializationStrategy - get() = ProRevocations.serializer() +) : ProApi(deps) { + override fun buildProRequest(): ProRequest = + BackendRequests.buildRevocationsRequest(ticket ?: 0L) - override fun convertErrorStatus(status: Int): Int = status - - override val endpoint: String - get() = "get_pro_revocations" + override fun parseResponse(json: String): GetProRevocationsResponse = + BackendRequests.parseRevocationsResponse(json) - override fun buildJsonBody(): String { - return json.encodeToString( - mapOf( - "ticket" to (ticket ?: 0L), - "version" to 0 - ) - ) - } + override fun convertErrorStatus(status: Int): Int = status @AssistedFactory interface Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 23f4ff07ea..68f18a26e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -1,10 +1,8 @@ package org.thoughtcrime.securesms.pro.api -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.decodeFromStream +import network.loki.messenger.libsession_util.pro.ProRequest +import network.loki.messenger.libsession_util.pro.ProResponse import okhttp3.HttpUrl.Companion.toHttpUrl import org.thoughtcrime.securesms.api.server.ServerApiErrorManager import org.thoughtcrime.securesms.api.ApiExecutorContext @@ -22,54 +20,47 @@ import javax.inject.Inject * @param ErrorStatus The type of error status returned by the API. * @param Res The type of the expected response. */ -abstract class ProApi(private val deps: ProApiDependencies) +abstract class ProApi(private val deps: ProApiDependencies) : ServerApi>(deps.errorManager) { - /** - * The endpoint (path) for this API request, e.g. "v1/pro/payments" - */ - abstract val endpoint: String - abstract val responseDeserializer: DeserializationStrategy + /** Builds the request (endpoint + signed JSON body) via libsession. */ + abstract fun buildProRequest(): ProRequest - abstract fun convertErrorStatus(status: Int): ErrorStatus + /** Parses the raw response body JSON into a typed struct via libsession. */ + abstract fun parseResponse(json: String): Res - abstract fun buildJsonBody(): String + abstract fun convertErrorStatus(status: Int): ErrorStatus override fun buildRequest( baseUrl: String, x25519PubKeyHex: String ): HttpRequest { + val request = buildProRequest() return HttpRequest( method = "POST", - url = "$baseUrl/$endpoint".toHttpUrl(), + url = "$baseUrl/${request.endpoint}".toHttpUrl(), headers = mapOf( "Content-Type" to "application/json" ), - body = HttpBody.Text(buildJsonBody()) + body = HttpBody.Text(request.body) ) } - @Suppress("OPT_IN_USAGE") override suspend fun handleSuccessResponse( executorContext: ApiExecutorContext, baseUrl: String, response: HttpResponse ): ProApiResponse { - val rawResp: RawProApiResponse = response.body - .asInputStream() - .use { deps.json.decodeFromStream(it) } + // libsession owns response parsing: hand it the raw body and get a typed struct back. + val bodyJson = response.body.asInputStream().use { it.readBytes().decodeToString() } + val parsed = parseResponse(bodyJson) - return if (rawResp.status == 0) { - val data = deps.json.decodeFromJsonElement( - responseDeserializer, - requireNotNull(rawResp.result) { - "Expected 'result' field to be present on successful response" - }) - ProApiResponse.Success(data) + return if (parsed.header.isSuccess) { + ProApiResponse.Success(parsed) } else { ProApiResponse.Failure( - status = convertErrorStatus(rawResp.status), - errors = rawResp.errors.orEmpty() + status = convertErrorStatus(parsed.header.status), + errors = parsed.header.errors ) } } @@ -78,13 +69,6 @@ abstract class ProApi(private val deps: ProApiDependencies) val errorManager: ServerApiErrorManager, val json: Json, ) - - @Serializable - private data class RawProApiResponse( - val status: Int, - val result: JsonElement? = null, - val errors: List? = null, - ) } From 7cfcf68dc341f629a1120c3091c1d5b334c8356d Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Sun, 19 Jul 2026 14:54:38 -0300 Subject: [PATCH 03/20] =?UTF-8?q?WIP:=20android=20app=20=E2=80=94=20read?= =?UTF-8?q?=20Pro=20backend=20URL/pubkey=20from=20libsession=20(single=20s?= =?UTF-8?q?ource)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provideProBackendConfig() now sources the backend URL + Ed25519 signing pubkey from the glue (BackendRequests.proBackendUrl() / proBackendPubKeyHex(), backed by session::pro_backend::URL / PUBKEY) instead of the BuildConfig.PRO_BACKEND_DEV copy — so a future URL/pubkey change happens only in libsession. x25519 is still derived on the fly in ProBackendConfig. (The now-unused PRO_BACKEND_DEV buildConfigField in app/build.gradle.kts can be removed separately.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/thoughtcrime/securesms/pro/ProModule.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt index b84eb66e91..033d40e76e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt @@ -4,13 +4,19 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import network.loki.messenger.BuildConfig +import network.loki.messenger.libsession_util.pro.BackendRequests @Module @InstallIn(SingletonComponent::class) class ProModule { @Provides fun provideProBackendConfig(): ProBackendConfig { - return BuildConfig.PRO_BACKEND_DEV + // The backend URL + Ed25519 signing pubkey come from libsession (single source of truth), so a + // future change happens in exactly one place rather than a per-client copy. x25519 is derived + // on the fly from the Ed key (see ProBackendConfig). + return ProBackendConfig( + url = BackendRequests.proBackendUrl(), + ed25519PubKeyHex = BackendRequests.proBackendPubKeyHex(), + ) } -} \ No newline at end of file +} From e074a754e9e0d0756881a6a8e3043e0163303643 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 09:54:30 -0300 Subject: [PATCH 04/20] android app: set Pro request Content-Type from libsession (not hardcoded) The glue's ProRequest now carries contentType; relay it as the Content-Type header instead of hardcoding "application/json", so the wire format stays a libsession<-> backend contract the app never assumes. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 68f18a26e6..04ffdafa19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -39,8 +39,9 @@ abstract class ProApi(private val deps: ProApiDe return HttpRequest( method = "POST", url = "$baseUrl/${request.endpoint}".toHttpUrl(), + // Content-Type comes from libsession (the wire format is its contract with the backend); we relay it headers = mapOf( - "Content-Type" to "application/json" + "Content-Type" to request.contentType ), body = HttpBody.Text(request.body) ) From 9927bb0f3b54f2f44099498a7df690b25d45c71f Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 12:42:55 -0300 Subject: [PATCH 05/20] WIP: add pro provider display-name strings (Crowdin stopgap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession no longer supplies provider display names (device/store/platform/ account) — they're client-owned i18n now. Hand-add the English values to values/strings.xml so R.string.proProvider* compiles for the app-side pro provider-metadata migration. TODO (Jason): these MUST be added upstream on Crowdin (project 618696) + approved + synced BEFORE the next translations regen, or the sync deletes them. Marker: TODO(crowdin) comment in strings.xml. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/res/values/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb8236f4..f551962aa7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1155,6 +1155,17 @@ 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month 12 Months - {monthly_price} / Month + + Google + Google Play Store + Android + Google account + Apple + Apple App Store + iOS + Apple account re-activating Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings. We’re sorry to see you go. Here\'s what you need to know before requesting a refund. From 9d7e4c93a8884a26e5a1c92bc860af6a978e5dae Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 16:37:07 -0300 Subject: [PATCH 06/20] =?UTF-8?q?WIP:=20android=20app=20=E2=80=94=20ProApi?= =?UTF-8?q?=20to=20Delta=20#12=20response=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the ErrorStatus generic + convertErrorStatus(Int) mechanism: the header is now a closed status enum + open error_code slug + diagnostic error. ProApi is single-type-param; a Failure carries a typed ProApiError(status, errorCode, error) with isRetryable (backend Error, or unknown_payment). Added ProErrorCode slug constants (§5.1). All *Api subclasses drop the ErrorStatus arg; deleted AddPaymentErrorStatus (int enum), GetProProofStatus, and the orphaned GetProRevocations @Serializable schema. addProPayment: already-redeemed special-case deleted (ok now always carries a proof — a re-claim succeeds), success saves data.proof, failures retry when retryable else raise PaymentServerException. Get-details consumers (ProDataMapper/workers/DB) still reference the old ProDetails shape — migrated in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../securesms/pro/ProStatusManager.kt | 37 +++++----- .../securesms/pro/api/AddProPaymentApi.kt | 15 +--- .../securesms/pro/api/GenerateProProofApi.kt | 8 +- .../securesms/pro/api/GetProDetailsApi.kt | 4 +- .../securesms/pro/api/GetProRevocationsApi.kt | 24 +----- .../thoughtcrime/securesms/pro/api/ProApi.kt | 73 ++++++++++++++----- 6 files changed, 76 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 64d15dd046..8331def133 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -36,6 +36,7 @@ import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY import network.loki.messenger.libsession_util.pro.ProConfig +import network.loki.messenger.libsession_util.pro.ProResponseStatus import network.loki.messenger.libsession_util.protocol.ProFeature import network.loki.messenger.libsession_util.protocol.ProMessageFeature import network.loki.messenger.libsession_util.protocol.ProProfileFeature @@ -63,7 +64,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.pro.api.AddPaymentErrorStatus +import org.thoughtcrime.securesms.pro.api.ProApiError import org.thoughtcrime.securesms.pro.api.AddProPaymentApi import org.thoughtcrime.securesms.pro.api.ProApiResponse import org.thoughtcrime.securesms.pro.api.ServerApiRequest @@ -503,7 +504,10 @@ class ProStatusManager @Inject constructor( "Timeout adding pro payment" } }.getOrElse { - ProApiResponse.Failure(AddPaymentErrorStatus.GenericError, emptyList()) + // Timed out / threw before we got a response — treat as a retryable backend error. + ProApiResponse.Failure( + ProApiError(ProResponseStatus.Error, errorCode = null, error = "add-payment request failed: $it") + ) } when (paymentResponse) { @@ -513,7 +517,11 @@ class ProStatusManager @Inject constructor( configFactory.get().withMutableUserConfigs { configs -> configs.userProfile.setProConfig( ProConfig( - proProof = paymentResponse.data, + // Delta #12 invariant: an `ok` add-payment always carries a proof + // (a re-claim of an already-redeemed payment now succeeds with one too). + proProof = requireNotNull(paymentResponse.data.proof) { + "add-payment returned ok without a proof" + }, rotatingPrivateKey = rotatingKeyPair.secretKey.data ) ) @@ -525,23 +533,14 @@ class ProStatusManager @Inject constructor( } is ProApiResponse.Failure -> { - // Handle payment failure Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' failure: $paymentResponse") - when (paymentResponse.status) { - // unknown payment is retryable - throw a generic exception here to go through our retries - AddPaymentErrorStatus.UnknownPayment -> { - throw Exception() - } - - // nothing to do if already redeemed - AddPaymentErrorStatus.AlreadyRedeemed -> { - return - } - - // non retryable error - throw our custom exception - AddPaymentErrorStatus.GenericError -> { - throw SubscriptionManager.PaymentServerException() - } + // Delta #12: `already_redeemed` is gone — a re-claim now returns ok + a proof + // (handled above). Retry transient failures (backend fault, or a payment the + // backend hasn't ingested yet); everything else is a hard, non-retryable failure. + if (paymentResponse.error.isRetryable) { + throw Exception() + } else { + throw SubscriptionManager.PaymentServerException() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt index 5944189ec2..cd49a66050 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt @@ -6,7 +6,6 @@ import dagger.assisted.AssistedInject import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.ProProofResponse import network.loki.messenger.libsession_util.pro.ProRequest -import org.session.libsignal.utilities.Log class AddProPaymentApi @AssistedInject constructor( @Assisted("token") private val googlePaymentToken: String, @@ -14,7 +13,7 @@ class AddProPaymentApi @AssistedInject constructor( @Assisted("master") private val masterPrivateKey: ByteArray, @Assisted private val rotatingPrivateKey: ByteArray, deps: ProApiDependencies -) : ProApi(deps) { +) : ProApi(deps) { override fun buildProRequest(): ProRequest = BackendRequests.buildAddProPaymentRequest( masterPrivateKey = masterPrivateKey, @@ -27,12 +26,6 @@ class AddProPaymentApi @AssistedInject constructor( override fun parseResponse(json: String): ProProofResponse = BackendRequests.parseAddPaymentResponse(json) - override fun convertErrorStatus(status: Int): AddPaymentErrorStatus { - Log.w("", "AddProPayment: convertErrorStatus: $status") - return AddPaymentErrorStatus.entries.firstOrNull { it.apiValue == status } - ?: AddPaymentErrorStatus.GenericError - } - @AssistedFactory interface Factory { fun create( @@ -43,9 +36,3 @@ class AddProPaymentApi @AssistedInject constructor( ): AddProPaymentApi } } - -enum class AddPaymentErrorStatus(val apiValue: Int) { - GenericError(1), - AlreadyRedeemed(100), - UnknownPayment(101), -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt index 6eebdafa90..39921c1c69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GenerateProProofApi.kt @@ -13,7 +13,7 @@ class GenerateProProofApi @AssistedInject constructor( @Assisted private val rotatingPrivateKey: ByteArray, private val snodeClock: SnodeClock, deps: ProApiDependencies, -) : ProApi(deps) { +) : ProApi(deps) { override fun buildProRequest(): ProRequest = BackendRequests.buildGenerateProProofRequest( @@ -25,8 +25,6 @@ class GenerateProProofApi @AssistedInject constructor( override fun parseResponse(json: String): ProProofResponse = BackendRequests.parseProProofResponse(json) - override fun convertErrorStatus(status: Int): GetProProofStatus = status - @AssistedFactory interface Factory { fun create( @@ -34,6 +32,4 @@ class GenerateProProofApi @AssistedInject constructor( rotatingPrivateKey: ByteArray, ): GenerateProProofApi } -} - -typealias GetProProofStatus = Int \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt index dfd3630c01..a697ab14fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt @@ -17,7 +17,7 @@ class GetProDetailsApi @AssistedInject constructor( private val snodeClock: SnodeClock, @Assisted private val masterPrivateKey: ByteArray, deps: ProApiDependencies, -) : ProApi(deps) { +) : ProApi(deps) { override fun buildProRequest(): ProRequest = BackendRequests.buildGetProDetailsRequest( masterPrivateKey = masterPrivateKey, @@ -28,8 +28,6 @@ class GetProDetailsApi @AssistedInject constructor( override fun parseResponse(json: String): GetProDetailsResponse = BackendRequests.parsePaymentDetailsResponse(json) - override fun convertErrorStatus(status: Int): Int = status - @AssistedFactory interface Factory { fun create(masterPrivateKey: ByteArray): GetProDetailsApi diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt index 8760d76dff..89a51745bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProRevocationsApi.kt @@ -10,37 +10,15 @@ import network.loki.messenger.libsession_util.pro.ProRequest class GetProRevocationApi @AssistedInject constructor( @Assisted private val ticket: Long?, deps: ProApiDependencies, -) : ProApi(deps) { +) : ProApi(deps) { override fun buildProRequest(): ProRequest = BackendRequests.buildRevocationsRequest(ticket ?: 0L) override fun parseResponse(json: String): GetProRevocationsResponse = BackendRequests.parseRevocationsResponse(json) - override fun convertErrorStatus(status: Int): Int = status - @AssistedFactory interface Factory { fun create(ticket: Long?): GetProRevocationApi } } - -@Serializable -class ProRevocations( - val ticket: Long, - val items: List, - @SerialName("retry_in") - val retryInSeconds: Long, - @SerialName("retain_for") - val retainForSeconds: Long, -) { - @Serializable - class Item( - @Serializable(with = InstantAsSecondsSerializer::class) - @SerialName("effective_ts") - val effectiveFrom: Instant, - - @SerialName("revocation_tag") - val revocationTag: String, - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 04ffdafa19..5ee0187eed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.pro.api import kotlinx.serialization.json.Json import network.loki.messenger.libsession_util.pro.ProRequest import network.loki.messenger.libsession_util.pro.ProResponse +import network.loki.messenger.libsession_util.pro.ProResponseStatus import okhttp3.HttpUrl.Companion.toHttpUrl import org.thoughtcrime.securesms.api.server.ServerApiErrorManager import org.thoughtcrime.securesms.api.ApiExecutorContext @@ -17,20 +18,17 @@ import javax.inject.Inject /** * Represents a generic API request to the Pro backend. * - * @param ErrorStatus The type of error status returned by the API. * @param Res The type of the expected response. */ -abstract class ProApi(private val deps: ProApiDependencies) - : ServerApi>(deps.errorManager) { +abstract class ProApi(private val deps: ProApiDependencies) + : ServerApi>(deps.errorManager) { - /** Builds the request (endpoint + signed JSON body) via libsession. */ + /** Builds the request (endpoint + signed body) via libsession. */ abstract fun buildProRequest(): ProRequest - /** Parses the raw response body JSON into a typed struct via libsession. */ + /** Parses the raw response body into a typed struct via libsession. */ abstract fun parseResponse(json: String): Res - abstract fun convertErrorStatus(status: Int): ErrorStatus - override fun buildRequest( baseUrl: String, x25519PubKeyHex: String @@ -51,7 +49,7 @@ abstract class ProApi(private val deps: ProApiDe executorContext: ApiExecutorContext, baseUrl: String, response: HttpResponse - ): ProApiResponse { + ): ProApiResponse { // libsession owns response parsing: hand it the raw body and get a typed struct back. val bodyJson = response.body.asInputStream().use { it.readBytes().decodeToString() } val parsed = parseResponse(bodyJson) @@ -60,8 +58,11 @@ abstract class ProApi(private val deps: ProApiDe ProApiResponse.Success(parsed) } else { ProApiResponse.Failure( - status = convertErrorStatus(parsed.header.status), - errors = parsed.header.errors + ProApiError( + status = parsed.header.status, + errorCode = parsed.header.errorCode, + error = parsed.header.error, + ) ) } } @@ -72,31 +73,63 @@ abstract class ProApi(private val deps: ProApiDe ) } +/** + * A failed Pro backend response (Delta #12). [status] is [ProResponseStatus.Fail] (client input / + * precondition) or [ProResponseStatus.Error] (backend fault, retryable). [errorCode] is the machine slug + * (see [ProErrorCode]; null if libsession supplied none) that the UI maps to a localized string; [error] + * is an English diagnostic — NOT user-facing (only shown when a slug has no translation), always safe to log. + */ +data class ProApiError( + val status: ProResponseStatus, + val errorCode: String?, + val error: String?, +) { + /** A backend fault is safe to retry as-is; so is a not-yet-visible payment (a transient race). */ + val isRetryable: Boolean + get() = status == ProResponseStatus.Error || errorCode == ProErrorCode.UNKNOWN_PAYMENT +} + +/** + * Canonical machine-readable error-code slugs (wire spec §5.1). The set is open/additive — an unrecognized + * slug is expected and handled by [status]-level logic + the diagnostic fallback, never a hard failure. + */ +object ProErrorCode { + const val INVALID_REQUEST = "invalid_request" + const val BAD_SIGNATURE = "bad_signature" + const val STALE_REQUEST = "stale_request" + const val UNKNOWN_PAYMENT = "unknown_payment" + const val EXPIRED = "expired" + const val NOT_SUBSCRIBED = "not_subscribed" + const val REVOKED = "revoked" + const val INTERNAL_ERROR = "internal_error" +} /** * Represents the response from a Pro API request. * * @param Res The type of the successful response data. */ -sealed interface ProApiResponse { - data class Success(val data: T) : ProApiResponse - data class Failure(val status: S, val errors: List) : ProApiResponse +sealed interface ProApiResponse { + data class Success(val data: T) : ProApiResponse + data class Failure(val error: ProApiError) : ProApiResponse } -fun ProApiResponse.successOrThrow(): T { +fun ProApiResponse.successOrThrow(): T { return when (this) { is ProApiResponse.Success -> this.data - is ProApiResponse.Failure -> throw RuntimeException("Fail with status = $status, errors = $errors") + is ProApiResponse.Failure -> throw RuntimeException( + "Pro API failed: status=${error.status}, code=${error.errorCode}, error=${error.error}" + ) } } -fun ServerApiRequest( +fun ServerApiRequest( proBackendConfig: ProBackendConfig, - api: ProApi -): ServerApiRequest> { - return ServerApiRequest>( + api: ProApi +): ServerApiRequest> { + return ServerApiRequest>( serverBaseUrl = proBackendConfig.url.toString(), serverX25519PubKeyHex = proBackendConfig.x25519PubKeyHex, api = api ) -} \ No newline at end of file +} From e55f4489ff9e048431d8d0300fe81101e7f9b0ea Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 16:43:14 -0300 Subject: [PATCH 07/20] =?UTF-8?q?WIP:=20android=20app=20=E2=80=94=20get-de?= =?UTF-8?q?tails=20display=20mapping=20off=20the=20clean=20glue=20struct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New app-side PaymentProviderMetadata (replaces the removed libsession type): display names from local proProvider* strings, URLs from the glue's providerUrls(slug). - ProDataMapper.toProStatus now maps GetProDetailsResponse: userStatus slug (never/active/expired via ProUserStatus; no account-level revoked), plan slug -> ProSubscriptionDuration, provider slug -> metadata, Instant/Duration fields. - ProStatus.providerData now uses the app-side PaymentProviderMetadata. Plumbing (workers/DB/repository/ProStatusManager call sites, delete orphaned ProDetails) + error_code->Crowdin follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../securesms/pro/PaymentProviderMetadata.kt | 55 +++++++++ .../securesms/pro/ProDataMapper.kt | 114 +++++++++--------- .../thoughtcrime/securesms/pro/ProStatus.kt | 1 - 3 files changed, 111 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt new file mode 100644 index 0000000000..bc6f23b460 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt @@ -0,0 +1,55 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import network.loki.messenger.R +import network.loki.messenger.libsession_util.pro.BackendRequests +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE + +/** + * App-facing per-provider metadata: the human-readable display names — client-owned i18n now that + * libsession no longer supplies them (Delta #10) — plus the support/management URLs, which are still + * libsession's (fetched via [BackendRequests.providerUrls] by the provider slug). Replaces the removed + * libsession `PaymentProviderMetadata` struct. + */ +data class PaymentProviderMetadata( + val device: String, + val store: String, + val platform: String, + val platformAccount: String, + val refundPlatformUrl: String, + val refundSupportUrl: String, + val refundStatusUrl: String, + val updateSubscriptionUrl: String, + val cancelSubscriptionUrl: String, +) + +/** + * Build [PaymentProviderMetadata] for an opaque provider slug: display strings from local resources (the + * `proProvider*` keys), URLs from libsession. + * + * TODO: an unknown/future slug currently falls back to the Google/Play presentation (the common case). A + * proper fix surfaces a generic "unknown provider ()" label — needs its own i18n key. + */ +fun providerMetadata(providerSlug: String, context: Context): PaymentProviderMetadata { + val urls = BackendRequests.providerUrls(providerSlug) + val isApple = providerSlug == PAYMENT_PROVIDER_APP_STORE + return PaymentProviderMetadata( + device = context.getString( + if (isApple) R.string.proProviderAppleDevice else R.string.proProviderGoogleDevice + ), + store = context.getString( + if (isApple) R.string.proProviderAppleStore else R.string.proProviderGoogleStore + ), + platform = context.getString( + if (isApple) R.string.proProviderApplePlatform else R.string.proProviderGooglePlatform + ), + platformAccount = context.getString( + if (isApple) R.string.proProviderAppleAccount else R.string.proProviderGoogleAccount + ), + refundPlatformUrl = urls?.refundPlatformUrl.orEmpty(), + refundSupportUrl = urls?.refundSupportUrl.orEmpty(), + refundStatusUrl = urls?.refundStatusUrl.orEmpty(), + updateSubscriptionUrl = urls?.updateSubscriptionUrl.orEmpty(), + cancelSubscriptionUrl = urls?.cancelSubscriptionUrl.orEmpty(), + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index ebb7f58687..bd1d0e0a1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -1,79 +1,80 @@ package org.thoughtcrime.securesms.pro -import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE +import android.content.Context import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY -import network.loki.messenger.libsession_util.pro.PaymentProvider -import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata -import org.thoughtcrime.securesms.pro.api.ServerPlanDuration -import org.thoughtcrime.securesms.pro.api.ProDetails -import org.thoughtcrime.securesms.pro.api.ProDetails.Companion.SERVER_PLAN_DURATION_12_MONTH -import org.thoughtcrime.securesms.pro.api.ProDetails.Companion.SERVER_PLAN_DURATION_3_MONTH +import network.loki.messenger.libsession_util.pro.GetProDetailsResponse import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import java.time.Duration import java.time.Instant -fun ProDetails.toProStatus(nowMs: Long): ProStatus { - return when (status) { - ProDetails.DETAILS_STATUS_ACTIVE -> { - val paymentItem = paymentItems.first() - - val expiryInstant = expiry!! - val expiryMs = expiryInstant.toEpochMilli() - val graceMs = graceDurationMs ?: 0L - - // beginAutoRenew / renew-due timestamp - val renewingAtMs = expiryMs - graceMs - val renewingAtInstant = Instant.ofEpochMilli(renewingAtMs) - - val isAutoRenewing = autoRenewing == true - val inGracePeriod = - isAutoRenewing && - nowMs >= renewingAtMs && - nowMs < expiryMs +/** + * Account-level Pro status slugs (get-details `user_status`, spec §5.2). Closed set on the backend: + * `never`/`active`/`expired` — note there is NO account-level `revoked` (that's a per-item payment + * status / an error_code). Opaque on the wire, so an unrecognized value is treated as "not subscribed". + */ +object ProUserStatus { + const val NEVER = "never" + const val ACTIVE = "active" + const val EXPIRED = "expired" +} - if (isAutoRenewing) { +/** + * Map a libsession-parsed get-details response to the app's [ProStatus] domain model. Needs a [Context] + * to resolve the (client-owned) provider display strings. + */ +fun GetProDetailsResponse.toProStatus(nowMs: Long, context: Context): ProStatus { + return when (userStatus) { + ProUserStatus.ACTIVE -> { + val paymentItem = items.firstOrNull() ?: return ProStatus.NeverSubscribed + // Access expiry (incl. grace); "renew due" is expiry minus the grace period. + val expiryMs = (expiry ?: return ProStatus.NeverSubscribed).toEpochMilli() + val renewingAtMs = expiryMs - gracePeriod.toMillis() + val renewingAt = Instant.ofEpochMilli(renewingAtMs) + val providerData = providerMetadata(paymentItem.paymentProvider, context) + val duration = paymentItem.plan.toSubscriptionDuration() + val refundInProgress = refundRequested != null + + if (autoRenewing) { ProStatus.Active.AutoRenewing( - renewingAt = renewingAtInstant, - duration = paymentItem.planDuration.toSubscriptionDuration(), - providerData = paymentItem.paymentProvider.getMetadata(), - quickRefundExpiry = paymentItem.platformExpiry, - refundInProgress = refundRequestedAtMs > 0, - inGracePeriod = inGracePeriod + renewingAt = renewingAt, + duration = duration, + providerData = providerData, + quickRefundExpiry = paymentItem.platformRefundExpiry, + refundInProgress = refundInProgress, + inGracePeriod = nowMs >= renewingAtMs && nowMs < expiryMs, ) } else { ProStatus.Active.Expiring( - renewingAt = renewingAtInstant, // will equal expiry when graceMs == 0 - duration = paymentItem.planDuration.toSubscriptionDuration(), - providerData = paymentItem.paymentProvider.getMetadata(), - quickRefundExpiry = paymentItem.platformExpiry, - refundInProgress = refundRequestedAtMs > 0 + renewingAt = renewingAt, // equals expiry when the grace period is zero + duration = duration, + providerData = providerData, + quickRefundExpiry = paymentItem.platformRefundExpiry, + refundInProgress = refundInProgress, ) } } - ProDetails.DETAILS_STATUS_EXPIRED -> ProStatus.Expired( - expiredAt = expiry!!, - providerData = paymentItems.first().paymentProvider.getMetadata() + ProUserStatus.EXPIRED -> ProStatus.Expired( + expiredAt = expiry ?: Instant.EPOCH, + providerData = providerMetadata( + items.firstOrNull()?.paymentProvider ?: PAYMENT_PROVIDER_GOOGLE_PLAY, + context, + ), ) + // "never" + any unrecognized/future slug -> treat as not subscribed. else -> ProStatus.NeverSubscribed } } -fun PaymentProvider.getMetadata(): PaymentProviderMetadata{ - return when(this){ - PAYMENT_PROVIDER_APP_STORE -> BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!! - else -> BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! - } -} - -fun ServerPlanDuration.toSubscriptionDuration(): ProSubscriptionDuration { - return when(this){ - SERVER_PLAN_DURATION_12_MONTH -> ProSubscriptionDuration.TWELVE_MONTHS - SERVER_PLAN_DURATION_3_MONTH -> ProSubscriptionDuration.THREE_MONTHS - else -> ProSubscriptionDuration.ONE_MONTH - } +/** + * Map an opaque billing-period slug (spec: `N` + unit `d`/`w`/`m`/`y`; canonical `"1m"`/`"3m"`/`"1y"`) + * to the app's subscription duration. Unknown/future codes fall back to the one-month presentation. + */ +fun String.toSubscriptionDuration(): ProSubscriptionDuration = when (this) { + "1y" -> ProSubscriptionDuration.TWELVE_MONTHS + "3m" -> ProSubscriptionDuration.THREE_MONTHS + else -> ProSubscriptionDuration.ONE_MONTH // "1m" + fallback } fun PaymentProviderMetadata.isFromAnotherPlatform(): Boolean { @@ -84,13 +85,12 @@ fun PaymentProviderMetadata.isFromAnotherPlatform(): Boolean { * Some UI cases require a special display name for the platform. */ fun PaymentProviderMetadata.getPlatformDisplayName(): String { - return when(platform.trim().lowercase()){ + return when (platform.trim().lowercase()) { "google" -> store else -> platform } } - /** * Preview Data - Reusable data for composable previews */ @@ -120,5 +120,3 @@ val previewExpiredApple = ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(14), providerData = previewAppleMetaData ) - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index 7f11072558..0889ccf168 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.pro import network.loki.messenger.BuildConfig -import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State From cba814a46165b29d626b8f8cdfc8890479e43b36 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 16:49:23 -0300 Subject: [PATCH 08/20] =?UTF-8?q?WIP:=20android=20app=20=E2=80=94=20cache/?= =?UTF-8?q?consume=20the=20clean=20GetProDetailsResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the get-details plumbing off the deleted wire-vestige ProDetails onto the clean @Serializable glue struct: - ProDatabase caches GetProDetailsResponse (tolerates a stale/incompatible blob by dropping it -> refetch); repository LoadState + workers use it. - FetchProDetailsWorker / ProProofGenerationWorker: userStatus slug (ProUserStatus) instead of the int DETAILS_STATUS_*; ok-responses carry a proof (requireNotNull). - ProStatusManager: toProStatus(now, context); debug mock states + non-originating UI build PaymentProviderMetadata via providerMetadata(slug, context). - Delete the orphaned ProDetails / ServerProDetailsStatus / DETAILS_STATUS_*. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../prosettings/CancelPlanNonOriginating.kt | 2 +- .../securesms/pro/FetchProDetailsWorker.kt | 10 +-- .../securesms/pro/ProDetailsRepository.kt | 12 +-- .../securesms/pro/ProProofGenerationWorker.kt | 7 +- .../securesms/pro/ProStatusManager.kt | 20 ++--- .../securesms/pro/api/GetProDetailsApi.kt | 88 ------------------- .../securesms/pro/db/ProDatabase.kt | 13 +-- 7 files changed, 34 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index ead72e8cd6..25006c9368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -9,7 +9,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase import network.loki.messenger.R -import network.loki.messenger.libsession_util.protocol.PaymentProviderMetadata +import org.thoughtcrime.securesms.pro.PaymentProviderMetadata import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 7193a007f9..7989bd5fad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -28,8 +28,8 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.api.server.ServerApiExecutor import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository +import network.loki.messenger.libsession_util.pro.GetProDetailsResponse import org.thoughtcrime.securesms.pro.api.GetProDetailsApi -import org.thoughtcrime.securesms.pro.api.ProDetails import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.pro.db.ProDatabase @@ -79,7 +79,7 @@ class FetchProDetailsWorker @AssistedInject constructor( Log.d( TAG, - "Fetched pro details, status = ${details.status}, " + + "Fetched pro details, status = ${details.userStatus}, " + "autoRenew = ${details.autoRenewing}, expiry = ${details.expiry}" ) @@ -92,7 +92,7 @@ class FetchProDetailsWorker @AssistedInject constructor( // Remove the pro config immediately if we know we are not pro anymore. // We will schedule proof generation below if we are still pro. - if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { + if (details.userStatus != ProUserStatus.ACTIVE) { configs.userProfile.removeProConfig() } } @@ -114,10 +114,10 @@ class FetchProDetailsWorker @AssistedInject constructor( } - private suspend fun scheduleProofGenerationIfNeeded(details: ProDetails) { + private suspend fun scheduleProofGenerationIfNeeded(details: GetProDetailsResponse) { val now = snodeClock.currentTimeMillis() - if (details.status != ProDetails.DETAILS_STATUS_ACTIVE) { + if (details.userStatus != ProUserStatus.ACTIVE) { Log.d(TAG, "Pro is not active, cancelling any existing proof generation work") ProProofGenerationWorker.cancel(context) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt index 5f66b6cda1..02e4124d6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt @@ -17,7 +17,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope -import org.thoughtcrime.securesms.pro.api.ProDetails +import network.loki.messenger.libsession_util.pro.GetProDetailsResponse import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.util.NetworkConnectivity import java.time.Instant @@ -35,20 +35,20 @@ class ProDetailsRepository @Inject constructor( private val networkConnectivity: NetworkConnectivity, ) { sealed interface LoadState { - val lastUpdated: Pair? + val lastUpdated: Pair? data object Init : LoadState { - override val lastUpdated: Pair? + override val lastUpdated: Pair? get() = null } data class Loading( - override val lastUpdated: Pair?, + override val lastUpdated: Pair?, val waitingForNetwork: Boolean ) : LoadState - data class Loaded(override val lastUpdated: Pair) : LoadState - data class Error(override val lastUpdated: Pair?) : LoadState + data class Loaded(override val lastUpdated: Pair) : LoadState + data class Error(override val lastUpdated: Pair?) : LoadState } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index 09f24dca26..ef7547deaf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -25,7 +25,6 @@ import org.thoughtcrime.securesms.api.server.ServerApiExecutor import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.pro.api.GenerateProProofApi -import org.thoughtcrime.securesms.pro.api.ProDetails import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.util.findCause @@ -60,14 +59,14 @@ class ProProofGenerationWorker @AssistedInject constructor( "Pro details must be available to generate proof" } - check(details.first.status == ProDetails.DETAILS_STATUS_ACTIVE) { + check(details.first.userStatus == ProUserStatus.ACTIVE) { "Pro status must be active to generate proof" } return try { val rotatingPrivateKey = ED25519.generate(null).secretKey.data - val proof = apiExecutor.execute( + val response = apiExecutor.execute( ServerApiRequest( proBackendConfig = proBackendConfig.get(), api = generateProProofApi.create( @@ -76,6 +75,8 @@ class ProProofGenerationWorker @AssistedInject constructor( ), ) ).successOrThrow() + // Delta #12 invariant: an `ok` proof response always carries the proof. + val proof = requireNotNull(response.proof) { "generate-proof returned ok without a proof" } configFactory.withMutableUserConfigs { it.userProfile.setProConfig(ProConfig( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 8331def133..a62142c393 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -141,7 +141,7 @@ class ProStatusManager @Inject constructor( val nowMs = snodeClock.currentTimeMillis() ProDataState( - type = proDetailsState.lastUpdated?.first?.toProStatus(nowMs) ?: ProStatus.NeverSubscribed, + type = proDetailsState.lastUpdated?.first?.toProStatus(nowMs, application) ?: ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -155,7 +155,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false @@ -164,7 +164,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE_REFUNDING -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.THREE_MONTHS, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = true, inGracePeriod = false @@ -173,7 +173,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(2), duration = ProSubscriptionDuration.TWELVE_MONTHS, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false ) @@ -181,7 +181,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(40), duration = ProSubscriptionDuration.TWELVE_MONTHS, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false ) @@ -189,7 +189,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), duration = ProSubscriptionDuration.ONE_MONTH, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false @@ -198,22 +198,22 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(2), duration = ProSubscriptionDuration.ONE_MONTH, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(14), - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER -> ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(60), - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!! + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(14), - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!! + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application) ) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt index a697ab14fb..78cc845352 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt @@ -3,15 +3,10 @@ package org.thoughtcrime.securesms.pro.api import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable import network.loki.messenger.libsession_util.pro.BackendRequests import network.loki.messenger.libsession_util.pro.GetProDetailsResponse import network.loki.messenger.libsession_util.pro.ProRequest import org.session.libsession.network.SnodeClock -import org.session.libsession.utilities.serializable.InstantAsSecondsSerializer -import java.time.Instant class GetProDetailsApi @AssistedInject constructor( private val snodeClock: SnodeClock, @@ -33,86 +28,3 @@ class GetProDetailsApi @AssistedInject constructor( fun create(masterPrivateKey: ByteArray): GetProDetailsApi } } - -typealias ServerProDetailsStatus = Int - -@Serializable -class ProDetails( - val status: ServerProDetailsStatus, - - @SerialName("auto_renewing") - val autoRenewing: Boolean? = null, - - @SerialName("expiry_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val expiry: Instant? = null, - - @SerialName("grace_period_duration") - val graceDuration: Long? = null, - - @SerialName("error_report") - val errorReport: Int? = null, - - @SerialName("payments_total") - val paymentsTotal: Int? = null, - - @SerialName("items") - val paymentItems: List = emptyList(), - - @SerialName("refund_requested_ts") - val refundRequestedAtSeconds: Long = 0, - - - - val version: Int, -) { - init { - check((status != DETAILS_STATUS_ACTIVE && status != DETAILS_STATUS_EXPIRED) || expiry != null) { "Expiry must not be null for state other than 'never subscribed'" } - check((status != DETAILS_STATUS_ACTIVE && status != DETAILS_STATUS_EXPIRED) || paymentItems.isNotEmpty()) { "Can't have no payment items for state other than 'never subscribed'" } - } - - @Serializable - data class Item( - @SerialName("plan") - val plan: String, // period code, e.g. "1m" / "3m" / "1y" - - // Payment status code [unredeemed, redeemed, expired, revoked] - we do not use it in the clients - val status: String? = null, - - @SerialName("payment_provider") - val paymentProvider: String, // opaque provider slug, e.g. "google_play" / "app_store" - - @SerialName("expiry_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val expiry: Instant? = null, - - @SerialName("grace_period_duration") - val graceDuration: Long? = null, - - @SerialName("platform_refund_expiry_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val platformExpiry: Instant? = null, - - @SerialName("redeemed_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val timeRedeemed: Instant? = null, - - @SerialName("unredeemed_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val timeUnredeemed: Instant? = null, - - @SerialName("revoked_ts") - @Serializable(with = InstantAsSecondsSerializer::class) - val timeRevoked: Instant? = null, - - // Opaque per-provider payment id (Google: "|"; others: their id verbatim) - @SerialName("payment_id") - val paymentId: String? = null, - ) - - companion object { - const val DETAILS_STATUS_NEVER_BEEN_PRO: ServerProDetailsStatus = 0 - const val DETAILS_STATUS_ACTIVE: ServerProDetailsStatus = 1 - const val DETAILS_STATUS_EXPIRED: ServerProDetailsStatus = 2 - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 0c07086032..d6a697b1d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.json.Json import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import org.thoughtcrime.securesms.pro.api.ProDetails +import network.loki.messenger.libsession_util.pro.GetProDetailsResponse import org.thoughtcrime.securesms.pro.api.ProRevocations import org.thoughtcrime.securesms.util.asSequence import java.time.Instant @@ -143,17 +143,20 @@ class ProDatabase @Inject constructor( val proDetailsChangeNotification: SharedFlow get() = mutableProDetailsChangeNotification - fun getProDetailsAndLastUpdated(): Pair? { + fun getProDetailsAndLastUpdated(): Pair? { return readableDatabase.query(""" SELECT name, value FROM pro_state WHERE name IN (?, ?) """, arrayOf(STATE_PRO_DETAILS, STATE_PRO_DETAILS_UPDATED_AT)).use { cursor -> - var details: ProDetails? = null + var details: GetProDetailsResponse? = null var updatedAt: Instant? = null while (cursor.moveToNext()) { when (val name = cursor.getString(0)) { - STATE_PRO_DETAILS -> details = json.decodeFromString(cursor.getString(1)) + // Tolerate a stale/incompatible cached blob (e.g. an older shape): drop it and let + // the next fetch repopulate, rather than throwing. + STATE_PRO_DETAILS -> details = + runCatching { json.decodeFromString(cursor.getString(1)) }.getOrNull() STATE_PRO_DETAILS_UPDATED_AT -> updatedAt = Instant.ofEpochMilli(cursor.getString(1).toLong()) else -> error("Unexpected state name $name") } @@ -167,7 +170,7 @@ class ProDatabase @Inject constructor( } } - fun updateProDetails(proDetails: ProDetails, updatedAt: Instant) { + fun updateProDetails(proDetails: GetProDetailsResponse, updatedAt: Instant) { val changes = writableDatabase.compileStatement(""" INSERT INTO pro_state (name, value) VALUES (?, ?), (?, ?) From f5f74f924a56c7d66819e51eb410a8785ef84fd6 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 20 Jul 2026 23:46:51 -0300 Subject: [PATCH 09/20] Pro: dynamic provider display, {pro_stores} store list, and error_code messages Resolve provider display names from pro_provider__ (with unknown/slug fallback); build the {pro_stores} purchasable-store list from BackendRequests.visiblePlatforms() (google_play first, gated on the store translation existing); map error_code slugs to localized pro_error_ strings. Removes the hardcoded DEFAULT_*_STORE constants and PLATFORM_STORE2_KEY. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../libsession/utilities/StringSubKeys.kt | 2 +- .../chooseplan/ChoosePlanNoBilling.kt | 29 +++---- .../securesms/pro/PaymentProviderMetadata.kt | 80 ++++++++++++++----- .../securesms/pro/ProErrorMessage.kt | 56 +++++++++++++ .../securesms/pro/ProStatusManager.kt | 12 ++- .../pro/subscription/SubscriptionManager.kt | 9 +++ 6 files changed, 146 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/pro/ProErrorMessage.kt diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index a3e6f2cf27..df2750ecf7 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -55,7 +55,7 @@ object StringSubstitutionConstants { const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular" const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" - const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other" + const val PRO_STORES_KEY: StringSubKey = "pro_stores" const val PLATFORM_ACCOUNT_KEY: StringSubKey = "platform_account" const val MONTHLY_PRICE_KEY: StringSubKey = "monthly_price" const val PRICE_KEY: StringSubKey = "price" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 0c1a4be12b..f387ced1e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -17,16 +17,18 @@ import org.session.libsession.utilities.StringSubstitutionConstants.BUILD_VARIAN import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE2_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_STORES_KEY import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY import org.thoughtcrime.securesms.pro.ProStatus -import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.buildProStoresList import org.thoughtcrime.securesms.pro.getPlatformDisplayName +import org.thoughtcrime.securesms.pro.providerStoreName import org.thoughtcrime.securesms.pro.previewExpiredApple import org.thoughtcrime.securesms.ui.components.iconExternalLink import org.thoughtcrime.securesms.ui.theme.PreviewTheme @@ -42,8 +44,7 @@ fun ChoosePlanNoBilling( ){ val context = LocalContext.current - val defaultGoogleStore = ProStatusManager.DEFAULT_GOOGLE_STORE - val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE + val proStores = buildProStoresList(context) val headerTitle = when(subscription) { is ProStatus.Expired -> Phrase.from(context.getText(R.string.proAccessRenewStart)) @@ -73,25 +74,19 @@ fun ChoosePlanNoBilling( val contentDescription: CharSequence = when(subscription) { is ProStatus.Expired -> Phrase.from(context.getText(R.string.proRenewingNoAccessBilling)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(PRO_STORES_KEY, proStores) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { "fdroid" -> "F-Droid Store" "huawei" -> "Huawei App Gallery" else -> "APK" }) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(ICON_KEY, iconExternalLink) .format() is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeNoAccessBilling)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(PRO_STORES_KEY, proStores) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { "fdroid" -> "F-Droid Store" @@ -114,16 +109,14 @@ fun ChoosePlanNoBilling( is ProStatus.Expired -> Phrase.from(context.getText(R.string.proRenewDesktopLinked)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(PRO_STORES_KEY, proStores) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeDesktopLinked)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(PRO_STORES_KEY, proStores) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() @@ -133,14 +126,14 @@ fun ChoosePlanNoBilling( val cell2Text: CharSequence = when(subscription) { is ProStatus.Expired -> Phrase.from(context.getText(R.string.proNewInstallationDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, providerStoreName(PAYMENT_PROVIDER_GOOGLE_PLAY, context)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() is ProStatus.NeverSubscribed -> Phrase.from(context.getText(R.string.proNewInstallationUpgrade)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, providerStoreName(PAYMENT_PROVIDER_GOOGLE_PLAY, context)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt index bc6f23b460..a2f0c0f0a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt @@ -1,9 +1,8 @@ package org.thoughtcrime.securesms.pro import android.content.Context -import network.loki.messenger.R +import com.squareup.phrase.Phrase import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE /** * App-facing per-provider metadata: the human-readable display names — client-owned i18n now that @@ -24,28 +23,17 @@ data class PaymentProviderMetadata( ) /** - * Build [PaymentProviderMetadata] for an opaque provider slug: display strings from local resources (the - * `proProvider*` keys), URLs from libsession. - * - * TODO: an unknown/future slug currently falls back to the Google/Play presentation (the common case). A - * proper fix surfaces a generic "unknown provider ()" label — needs its own i18n key. + * Build [PaymentProviderMetadata] for an opaque provider slug: display strings resolved dynamically from + * `pro_provider__` resources (so a new provider needs only translations, not a code change), + * URLs from libsession. */ fun providerMetadata(providerSlug: String, context: Context): PaymentProviderMetadata { val urls = BackendRequests.providerUrls(providerSlug) - val isApple = providerSlug == PAYMENT_PROVIDER_APP_STORE return PaymentProviderMetadata( - device = context.getString( - if (isApple) R.string.proProviderAppleDevice else R.string.proProviderGoogleDevice - ), - store = context.getString( - if (isApple) R.string.proProviderAppleStore else R.string.proProviderGoogleStore - ), - platform = context.getString( - if (isApple) R.string.proProviderApplePlatform else R.string.proProviderGooglePlatform - ), - platformAccount = context.getString( - if (isApple) R.string.proProviderAppleAccount else R.string.proProviderGoogleAccount - ), + device = providerDisplay(context, providerSlug, "device"), + store = providerDisplay(context, providerSlug, "store"), + platform = providerDisplay(context, providerSlug, "platform"), + platformAccount = providerDisplay(context, providerSlug, "account"), refundPlatformUrl = urls?.refundPlatformUrl.orEmpty(), refundSupportUrl = urls?.refundSupportUrl.orEmpty(), refundStatusUrl = urls?.refundStatusUrl.orEmpty(), @@ -53,3 +41,55 @@ fun providerMetadata(providerSlug: String, context: Context): PaymentProviderMet cancelSubscriptionUrl = urls?.cancelSubscriptionUrl.orEmpty(), ) } + +/** + * Resolve one provider display field for [slug]: + * 1. `pro_provider__` if that string resource exists; + * 2. else `pro_provider_unknown_`, substituting the raw slug for `{provider}` when the template + * carries it (forward-compatible: an unknown/untranslated provider still renders sanely); + * 3. else the raw slug (last resort — never throws). + */ +private fun providerDisplay(context: Context, slug: String, suffix: String): String { + val res = context.resources + val pkg = context.packageName + + val specific = res.getIdentifier("pro_provider_${slug}_$suffix", "string", pkg) + if (specific != 0) return context.getString(specific) + + val unknown = res.getIdentifier("pro_provider_unknown_$suffix", "string", pkg) + if (unknown != 0) { + return runCatching { + val pattern = res.getString(unknown) + val phrase = Phrase.from(context, unknown) + // Phrase is strict — only put {provider} when the template actually contains it. + if (pattern.contains("{provider}")) phrase.put("provider", slug) + phrase.format().toString() + }.getOrDefault(slug) + } + + return slug +} + +/** Localized store name for a provider slug (`pro_provider__store`), with the unknown/slug fallback. */ +fun providerStoreName(providerSlug: String, context: Context): String = + providerDisplay(context, providerSlug, "store") + +/** + * Build the `{pro_stores}` bulleted list of purchasable stores: the visible provider slugs from + * libsession (this platform's own — Google Play — hoisted first, the rest keeping libsession's order), + * keeping only those with a `pro_provider__store` translation (an unknown/untranslated provider is + * skipped). Joined with `\n• ` — android renders line breaks as `\n` (the pipeline converts `
`→`\n`). + */ +fun buildProStoresList(context: Context): String { + val slugs = BackendRequests.visiblePlatforms().toMutableList() + val ownIndex = slugs.indexOf(BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY) + if (ownIndex > 0) { + slugs.add(0, slugs.removeAt(ownIndex)) + } + return slugs + .mapNotNull { slug -> + val resId = context.resources.getIdentifier("pro_provider_${slug}_store", "string", context.packageName) + if (resId != 0) context.getString(resId) else null + } + .joinToString(separator = "") { "\n• $it" } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProErrorMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProErrorMessage.kt new file mode 100644 index 0000000000..96f4ac2210 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProErrorMessage.kt @@ -0,0 +1,56 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.pro.api.ProApiError + +/** + * Non-translatable brand tokens a `pro_error_` string may contain, filled at runtime. + * + * TODO: this conditional-substitution dance only exists because android substitutes brand tokens + * at runtime (Phrase is strict: putting an absent token throws). Once brand tokens are baked in at + * build time (the AUTO_REPLACE_STATIC_STRINGS migration), the strings arrive already-substituted and + * this whole helper collapses to a plain resource lookup. + */ +private val PRO_ERROR_BRAND_SUBS = listOf( + PRO_KEY to NonTranslatableStringConstants.PRO, + APP_PRO_KEY to NonTranslatableStringConstants.APP_PRO, + APP_NAME_KEY to NonTranslatableStringConstants.APP_NAME, +) + +/** + * Resolve a failed Pro backend request to a user-facing message. + * + * The backend sends an open-ended [ProApiError.errorCode] slug (wire spec §5.1) plus an English + * diagnostic [ProApiError.error]. We prefer a localized `pro_error_` string when one exists + * (a brand-new slug therefore needs only a translation entry + rebuild — no code change), and fall + * back to the backend diagnostic, then a generic message. + * + * 1. localized `pro_error_` if that string resource exists (base English counts), with any + * brand tokens ({pro}/{app_pro}/{app_name}) substituted when present; + * 2. else the backend English diagnostic [ProApiError.error]; + * 3. else [R.string.errorGeneric]. + */ +fun ProApiError.userFacingMessage(context: Context): String { + errorCode?.let { slug -> + val resId = context.resources.getIdentifier("pro_error_$slug", "string", context.packageName) + if (resId != 0) { + // getIdentifier only tells us the resource exists; the format() below can still throw if the + // string carries an arg token we don't fill — in that case fall through to the diagnostic. + runCatching { + val pattern = context.resources.getString(resId) + val phrase = Phrase.from(context, resId) + for ((key, value) in PRO_ERROR_BRAND_SUBS) { + if (pattern.contains("{$key}")) phrase.put(key, value) + } + phrase.format().toString() + }.getOrNull()?.let { return it } + } + } + return error ?: context.getString(R.string.errorGeneric) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index a62142c393..8bb1891408 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -540,7 +540,11 @@ class ProStatusManager @Inject constructor( if (paymentResponse.error.isRetryable) { throw Exception() } else { - throw SubscriptionManager.PaymentServerException() + // Permanent fault: surface the specific reason (error_code slug -> + // localized string, falling back to the backend diagnostic). + throw SubscriptionManager.NonRetryableProPaymentException( + paymentResponse.error.userFacingMessage(application) + ) } } } @@ -550,6 +554,10 @@ class ProStatusManager @Inject constructor( // rethrow this error directly without retrying Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' PaymentServerException caught and rethrown") throw e + } catch (e: SubscriptionManager.NonRetryableProPaymentException){ + // permanent, non-retryable fault — rethrow directly so we don't retry or swallow it + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' NonRetryableProPaymentException caught and rethrown") + throw e }catch (e: Exception) { Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' exception", e) // If not the last attempt, backoff a little and retry @@ -572,7 +580,5 @@ class ProStatusManager @Inject constructor( const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" - const val DEFAULT_GOOGLE_STORE = "Google Play Store" - const val DEFAULT_APPLE_STORE = "Apple App Store" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 1e26ff0f88..23caf40efa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -79,6 +79,12 @@ abstract class SubscriptionManager( ) ) } + // A non-retryable backend failure carries a user-facing reason (mapped from the + // error_code slug); show it directly rather than the retry dialog, which would be + // misleading for a permanent fault. + is NonRetryableProPaymentException -> { + _purchaseEvents.emit(PurchaseEvent.Failed.GenericError(e.userMessage)) + } else -> _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) } } @@ -87,6 +93,9 @@ abstract class SubscriptionManager( class PaymentServerException: Exception() + /** A non-retryable Pro backend failure whose [userMessage] is already localized for display. */ + class NonRetryableProPaymentException(val userMessage: String): Exception() + data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, val priceAmountMicros: Long, From ee8cec0dd681b93c238029a3dbec09286cddafe5 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 21 Jul 2026 21:55:52 -0300 Subject: [PATCH 10/20] Pro: rename genIndexHash -> revocationTag; fix stale pro_revocations table schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pro_revocations CREATE TABLE still declared the old gen_index_hash/expiry_ms columns while all queries used revocation_tag/effective_ts/retain_until_ts — inserts would have hit missing columns. Fixed the schema to match. No migration (Session Pro has not shipped). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thoughtcrime/securesms/database/RecipientRepository.kt | 2 +- .../java/org/thoughtcrime/securesms/pro/ProStatusManager.kt | 4 ++-- .../java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 04c06766dc..dc10149c88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -784,7 +784,7 @@ class RecipientRepository @Inject constructor( RecipientSettings.ProData( showProBadge = contact.proFeatures.contains(ProProfileFeature.PRO_BADGE), expiry = convo.proProofInfo!!.expiry, - genIndexHash = convo.proProofInfo!!.genIndexHash.data.toHexString(), + revocationTag = convo.proProofInfo!!.revocationTag.data.toHexString(), ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 8bb1891408..a9cba599c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -379,8 +379,8 @@ class ProStatusManager @Inject constructor( proDatabase.revocationChangeNotification .onStart { emit(Unit) }, - { proofGenIndexHash, _ -> - proofGenIndexHash.takeIf { proDatabase.isRevoked(it, snodeClock.currentTime()) } + { proofRevocationTag, _ -> + proofRevocationTag.takeIf { proDatabase.isRevoked(it, snodeClock.currentTime()) } } ) .filterNotNull() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index d6a697b1d2..449417aa65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -206,8 +206,9 @@ class ProDatabase @Inject constructor( //language=roomsql db.execSQL(""" CREATE TABLE pro_revocations( - gen_index_hash TEXT NOT NULL PRIMARY KEY, - expiry_ms INTEGER NOT NULL + revocation_tag TEXT NOT NULL PRIMARY KEY, + effective_ts INTEGER NOT NULL, + retain_until_ts INTEGER NOT NULL ) WITHOUT ROWID """) From 7e14629bb6a39b00072b5e7d5cdeb508404adcd7 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Tue, 21 Jul 2026 23:22:02 -0300 Subject: [PATCH 11/20] Pro: restore original createTable schema; rely on reshape migration My earlier fix edited ProDatabase.createTable (the lokiV57 migration step) to the new revocation_tag/effective_ts/retain_until_ts schema. That is the wrong place to change: createTable is replayed as the lokiV57 upgrade step for installs coming from older versions, so it must keep the shape it originally shipped with. The actual schema change is already handled correctly by reshapeRevocationsForSeconds (lokiV61), which DROPs and recreates the table with the seconds schema and runs for every upgrading and fresh install. Restore createTable to the original gen_index_hash/expiry_ms shape so the migration history is honest; reshape supersedes it. No functional change (reshape is terminal and always runs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/thoughtcrime/securesms/pro/db/ProDatabase.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 449417aa65..616f71593f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -202,13 +202,15 @@ class ProDatabase @Inject constructor( private const val ROTATING_KEY_VALIDITY_DAYS = 15 fun createTable(db: SupportSQLiteDatabase) { - // A table to hold the list of pro revocations + // A table to hold the list of pro revocations. This is the ORIGINAL (lokiV57) shipped + // shape; `reshapeRevocationsForSeconds` (lokiV61) drops and recreates it with the + // current seconds-based schema, so do NOT edit this to match the new columns — installs + // that already ran lokiV57 must see the same schema here that they got when they shipped. //language=roomsql db.execSQL(""" CREATE TABLE pro_revocations( - revocation_tag TEXT NOT NULL PRIMARY KEY, - effective_ts INTEGER NOT NULL, - retain_until_ts INTEGER NOT NULL + gen_index_hash TEXT NOT NULL PRIMARY KEY, + expiry_ms INTEGER NOT NULL ) WITHOUT ROWID """) From 45fe39ccc0884af3a0ec8e887c8f1a17d1451acc Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 13:18:00 -0300 Subject: [PATCH 12/20] Pro: don't clear the (synced) proof on an unknown backend status FetchProDetailsWorker cleared the pro config whenever userStatus != ACTIVE, which also fires for any unknown/future status slug. removeProConfig() writes the SYNCED user profile, so an older client that doesn't recognise a new status would erase a still-valid proof and propagate the deletion to all the user's devices. Only clear on the authoritative terminal statuses (EXPIRED/NEVER); leave the proof untouched on an unrecognised status. Its own expiry governs entitlement, and the backend won't refresh (or will revoke) a genuinely-lapsed account. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../securesms/pro/FetchProDetailsWorker.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt index 7989bd5fad..365cc954cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt @@ -90,9 +90,15 @@ class FetchProDetailsWorker @AssistedInject constructor( configs.userProfile.removeProAccessExpiry() } - // Remove the pro config immediately if we know we are not pro anymore. - // We will schedule proof generation below if we are still pro. - if (details.userStatus != ProUserStatus.ACTIVE) { + // Remove the pro config only when the backend authoritatively says we are no longer + // pro (expired) or never were (never). An unknown/future status is NOT a basis to + // delete it: removeProConfig() writes the SYNCED user profile, so clearing on an + // unrecognised status would erase a valid proof across all the user's devices. Leave + // it — the proof's own expiry governs, and the backend won't refresh (or will revoke) + // a genuinely-lapsed account. We schedule proof generation below if we are still pro. + if (details.userStatus == ProUserStatus.EXPIRED || + details.userStatus == ProUserStatus.NEVER + ) { configs.userProfile.removeProConfig() } } From 8d615973b034dc228d2200c3b6b6f18082fe64a5 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 22 Jul 2026 22:22:36 -0300 Subject: [PATCH 13/20] Absorb get_pro_details -> get_pro_status split (libsession Delta #15) Move the app onto the split-out get_pro_status endpoint (the light "am I Pro?" call); the paginated get_payment_details history endpoint is library-only and is not wired (no android UI uses payment history). - api/GetProDetailsApi -> GetProStatusApi: buildGetProStatusRequest (drops the count=10 param, get_pro_status has no count) + parseProStatusResponse. - ProDataMapper: GetProStatusResponse; the single latestPayment replaces items.firstOrNull()/items[0]. Unknown-status handling is unchanged (proof is only cleared on EXPIRED/NEVER, never on an unknown status). - FetchProDetailsWorker -> FetchProStatusWorker, ProDetailsRepository -> ProStatusRepository, plus ProProofGenerationWorker / ProStatusManager / ProDatabase / settings view-models: full *ProDetails* -> *ProStatus* terminology purge (types, functions, workers, DB accessors). - ProDatabase: the persisted pro-status cache key is renamed pro_details -> pro_status. This orphans any previously cached blob; it is re-fetched on next poll and the feature is pre-release + gated by forcePostPro(), so this is benign. Pairs with the libsession-util-android glue commit that re-pins libsession to 3f8aace0 and reshapes GetProStatusResponse. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../preferences/SettingsViewModel.kt | 8 ++--- .../prosettings/ProSettingsViewModel.kt | 18 +++++----- ...tailsWorker.kt => FetchProStatusWorker.kt} | 36 +++++++++---------- .../securesms/pro/ProDataMapper.kt | 12 +++---- .../securesms/pro/ProProofGenerationWorker.kt | 8 ++--- .../securesms/pro/ProStatusManager.kt | 32 ++++++++--------- ...lsRepository.kt => ProStatusRepository.kt} | 30 ++++++++-------- ...GetProDetailsApi.kt => GetProStatusApi.kt} | 15 ++++---- .../securesms/pro/db/ProDatabase.kt | 32 ++++++++--------- 9 files changed, 95 insertions(+), 96 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/pro/{FetchProDetailsWorker.kt => FetchProStatusWorker.kt} (86%) rename app/src/main/java/org/thoughtcrime/securesms/pro/{ProDetailsRepository.kt => ProStatusRepository.kt} (77%) rename app/src/main/java/org/thoughtcrime/securesms/pro/api/{GetProDetailsApi.kt => GetProStatusApi.kt} (61%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 1d1ca48c42..5f7c7d1ccd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -59,7 +59,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.pro.ProDataState -import org.thoughtcrime.securesms.pro.ProDetailsRepository +import org.thoughtcrime.securesms.pro.ProStatusRepository import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData @@ -93,7 +93,7 @@ class SettingsViewModel @Inject constructor( private val inAppReviewManager: InAppReviewManager, private val avatarUploadManager: AvatarUploadManager, private val attachmentProcessor: AttachmentProcessor, - private val proDetailsRepository: ProDetailsRepository, + private val proStatusRepository: ProStatusRepository, private val donationManager: DonationManager, private val pathManager: PathManager, private val swarmApiExecutor: SwarmApiExecutor, @@ -172,9 +172,9 @@ class SettingsViewModel @Inject constructor( } } - // refreshes the pro details data + // refreshes the pro status data viewModelScope.launch { - proDetailsRepository.requestRefresh() + proStatusRepository.requestRefresh() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 0d10378d51..e52ebe2acc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -52,7 +52,7 @@ import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProDataState -import org.thoughtcrime.securesms.pro.ProDetailsRepository +import org.thoughtcrime.securesms.pro.ProStatusRepository import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData @@ -80,7 +80,7 @@ class ProSettingsViewModel @AssistedInject constructor( private val subscriptionCoordinator: SubscriptionCoordinator, private val dateUtils: DateUtils, private val prefs: TextSecurePreferences, - private val proDetailsRepository: ProDetailsRepository, + private val proStatusRepository: ProStatusRepository, private val configFactory: Lazy, private val storage: StorageProtocol, private val clock: SnodeClock, @@ -488,7 +488,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshProDetails(true) }, + onPositive = { refreshProStatus(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -577,12 +577,12 @@ class ProSettingsViewModel @AssistedInject constructor( is Commands.RecoverAccount -> { recovering = true - refreshProDetails(true) + refreshProStatus(true) } is Commands.OnUserBackFromCancellation -> { // refresh details - refreshProDetails(true) + refreshProStatus(true) // send action to handle post cancellation to the navigator viewModelScope.launch { @@ -753,7 +753,7 @@ class ProSettingsViewModel @AssistedInject constructor( negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, showXIcon = true, - onPositive = { refreshProDetails(true) }, + onPositive = { refreshProStatus(true) }, onNegative = { onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) } @@ -792,12 +792,12 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun refreshProDetails(force: Boolean){ + private fun refreshProStatus(force: Boolean){ // stop early if we are already refreshing if(_proSettingsUIState.value.proDataState.refreshState is State.Loading) return - // refreshes the pro details data - proDetailsRepository.requestRefresh(force = force) + // refreshes the pro status data + proStatusRepository.requestRefresh(force = force) } private fun getSelectedPlan(): ProPlan? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt similarity index 86% rename from app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt index 365cc954cb..cecfc99aba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProDetailsWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -28,8 +28,8 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.api.server.ServerApiExecutor import org.thoughtcrime.securesms.api.server.execute import org.thoughtcrime.securesms.auth.LoginStateRepository -import network.loki.messenger.libsession_util.pro.GetProDetailsResponse -import org.thoughtcrime.securesms.pro.api.GetProDetailsApi +import network.loki.messenger.libsession_util.pro.GetProStatusResponse +import org.thoughtcrime.securesms.pro.api.GetProStatusApi import org.thoughtcrime.securesms.pro.api.ServerApiRequest import org.thoughtcrime.securesms.pro.api.successOrThrow import org.thoughtcrime.securesms.pro.db.ProDatabase @@ -37,20 +37,20 @@ import java.time.Duration import javax.inject.Provider /** - * A worker that fetches the user's Pro details from the server and updates the local database. + * A worker that fetches the user's Pro status from the server and updates the local database. * * This worker doesn't do any business logic in terms of when to schedule itself, it simply performs * the fetch and update operation regardlessly. It, however, does schedule the [ProProofGenerationWorker] - * if needed based on the fetched Pro details, this is because the proof generation logic - * is tightly coupled to the fetched Pro details state. + * if needed based on the fetched Pro status, this is because the proof generation logic + * is tightly coupled to the fetched Pro status state. */ @HiltWorker -class FetchProDetailsWorker @AssistedInject constructor( +class FetchProStatusWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted params: WorkerParameters, private val proBackendConfig: Provider, private val serverApiExecutor: ServerApiExecutor, - private val getProDetailsApiFactory: GetProDetailsApi.Factory, + private val getProStatusApiFactory: GetProStatusApi.Factory, private val proDatabase: ProDatabase, private val loginStateRepository: LoginStateRepository, private val snodeClock: SnodeClock, @@ -59,27 +59,27 @@ class FetchProDetailsWorker @AssistedInject constructor( ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { if (!prefs.forcePostPro()) { - Log.d(TAG, "Pro details fetch skipped because pro is not enabled") + Log.d(TAG, "Pro status fetch skipped because pro is not enabled") return Result.success() } val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { - "User must be logged in to fetch pro details" + "User must be logged in to fetch pro status" } return try { - Log.d(TAG, "Fetching Pro details from server") + Log.d(TAG, "Fetching Pro status from server") val details = serverApiExecutor.execute( ServerApiRequest( proBackendConfig = proBackendConfig.get(), - api = getProDetailsApiFactory.create(proMasterKey) + api = getProStatusApiFactory.create(proMasterKey) ) ).successOrThrow() Log.d( TAG, - "Fetched pro details, status = ${details.userStatus}, " + + "Fetched pro status, status = ${details.userStatus}, " + "autoRenew = ${details.autoRenewing}, expiry = ${details.expiry}" ) @@ -102,7 +102,7 @@ class FetchProDetailsWorker @AssistedInject constructor( configs.userProfile.removeProConfig() } } - proDatabase.updateProDetails(proDetails = details, updatedAt = snodeClock.currentTime()) + proDatabase.updateProStatus(proStatus = details, updatedAt = snodeClock.currentTime()) scheduleProofGenerationIfNeeded(details) @@ -111,16 +111,16 @@ class FetchProDetailsWorker @AssistedInject constructor( Log.d(TAG, "Work cancelled") throw e } catch (e: NonRetryableException) { - Log.e(TAG, "Non-retryable error fetching pro details", e) + Log.e(TAG, "Non-retryable error fetching pro status", e) Result.failure() } catch (e: Exception) { - Log.e(TAG, "Error fetching pro details", e) + Log.e(TAG, "Error fetching pro status", e) Result.retry() } } - private suspend fun scheduleProofGenerationIfNeeded(details: GetProDetailsResponse) { + private suspend fun scheduleProofGenerationIfNeeded(details: GetProStatusResponse) { val now = snodeClock.currentTimeMillis() if (details.userStatus != ProUserStatus.ACTIVE) { @@ -152,7 +152,7 @@ class FetchProDetailsWorker @AssistedInject constructor( } companion object { - private const val TAG = "FetchProDetailsWorker" + private const val TAG = "FetchProStatusWorker" fun watch(context: Context): Flow { val workQuery = WorkQuery.Builder @@ -173,7 +173,7 @@ class FetchProDetailsWorker @AssistedInject constructor( .enqueueUniqueWork( uniqueWorkName = TAG, existingWorkPolicy = existingWorkPolicy, - request = OneTimeWorkRequestBuilder() + request = OneTimeWorkRequestBuilder() .apply { if (delay != null) { setInitialDelay(delay) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index bd1d0e0a1a..ea6df67482 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -2,13 +2,13 @@ package org.thoughtcrime.securesms.pro import android.content.Context import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY -import network.loki.messenger.libsession_util.pro.GetProDetailsResponse +import network.loki.messenger.libsession_util.pro.GetProStatusResponse import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import java.time.Duration import java.time.Instant /** - * Account-level Pro status slugs (get-details `user_status`, spec §5.2). Closed set on the backend: + * Account-level Pro status slugs (get-pro-status `user_status`, spec §5.2). Closed set on the backend: * `never`/`active`/`expired` — note there is NO account-level `revoked` (that's a per-item payment * status / an error_code). Opaque on the wire, so an unrecognized value is treated as "not subscribed". */ @@ -19,13 +19,13 @@ object ProUserStatus { } /** - * Map a libsession-parsed get-details response to the app's [ProStatus] domain model. Needs a [Context] + * Map a libsession-parsed get-pro-status response to the app's [ProStatus] domain model. Needs a [Context] * to resolve the (client-owned) provider display strings. */ -fun GetProDetailsResponse.toProStatus(nowMs: Long, context: Context): ProStatus { +fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { return when (userStatus) { ProUserStatus.ACTIVE -> { - val paymentItem = items.firstOrNull() ?: return ProStatus.NeverSubscribed + val paymentItem = latestPayment ?: return ProStatus.NeverSubscribed // Access expiry (incl. grace); "renew due" is expiry minus the grace period. val expiryMs = (expiry ?: return ProStatus.NeverSubscribed).toEpochMilli() val renewingAtMs = expiryMs - gracePeriod.toMillis() @@ -57,7 +57,7 @@ fun GetProDetailsResponse.toProStatus(nowMs: Long, context: Context): ProStatus ProUserStatus.EXPIRED -> ProStatus.Expired( expiredAt = expiry ?: Instant.EPOCH, providerData = providerMetadata( - items.firstOrNull()?.paymentProvider ?: PAYMENT_PROVIDER_GOOGLE_PLAY, + latestPayment?.paymentProvider ?: PAYMENT_PROVIDER_GOOGLE_PLAY, context, ), ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index ef7547deaf..db33e3aea5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -37,7 +37,7 @@ import javax.inject.Provider * locally. * * Normally you don't need to interact with this worker directly, as it is scheduled - * automatically when needed based on the Pro details state, by the [FetchProDetailsWorker]. + * automatically when needed based on the Pro status state, by the [FetchProStatusWorker]. */ @HiltWorker class ProProofGenerationWorker @AssistedInject constructor( @@ -46,7 +46,7 @@ class ProProofGenerationWorker @AssistedInject constructor( private val apiExecutor: ServerApiExecutor, private val proBackendConfig: Provider, private val generateProProofApi: GenerateProProofApi.Factory, - private val proDetailsRepository: ProDetailsRepository, + private val proStatusRepository: ProStatusRepository, private val loginStateRepository: LoginStateRepository, private val configFactory: ConfigFactoryProtocol, ) : CoroutineWorker(context, params) { @@ -55,8 +55,8 @@ class ProProofGenerationWorker @AssistedInject constructor( "User must be logged to generate proof" } - val details = checkNotNull(proDetailsRepository.loadState.value.lastUpdated) { - "Pro details must be available to generate proof" + val details = checkNotNull(proStatusRepository.loadState.value.lastUpdated) { + "Pro status must be available to generate proof" } check(details.first.userStatus == ProUserStatus.ACTIVE) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index a9cba599c8..997adb6236 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -93,7 +93,7 @@ class ProStatusManager @Inject constructor( private val loginState: LoginStateRepository, private val proDatabase: ProDatabase, private val snodeClock: SnodeClock, - private val proDetailsRepository: Lazy, + private val proStatusRepository: Lazy, private val configFactory: Lazy, ) : AuthAwareComponent { @@ -108,7 +108,7 @@ class ProStatusManager @Inject constructor( } } .distinctUntilChanged(), - proDetailsRepository.get().loadState, + proStatusRepository.get().loadState, (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) .onStart { emit(Unit) } .map { prefs.getDebugSubscriptionType() }, @@ -118,19 +118,19 @@ class ProStatusManager @Inject constructor( (TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } as Flow<*>) .onStart { emit(Unit) } .map { prefs.forceCurrentUserAsPro() }, - ){ showProBadgePreference, proDetailsState, + ){ showProBadgePreference, proStatusState, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> val proDataRefreshState = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) else -> { // calculate the real refresh state here - when(proDetailsState){ - is ProDetailsRepository.LoadState.Loading -> { - if(proDetailsState.waitingForNetwork) State.Error(Exception()) + when(proStatusState){ + is ProStatusRepository.LoadState.Loading -> { + if(proStatusState.waitingForNetwork) State.Error(Exception()) else State.Loading } - is ProDetailsRepository.LoadState.Error -> State.Error(Exception()) + is ProStatusRepository.LoadState.Error -> State.Error(Exception()) else -> State.Success(Unit) } } @@ -141,7 +141,7 @@ class ProStatusManager @Inject constructor( val nowMs = snodeClock.currentTimeMillis() ProDataState( - type = proDetailsState.lastUpdated?.first?.toProStatus(nowMs, application) ?: ProStatus.NeverSubscribed, + type = proStatusState.lastUpdated?.first?.toProStatus(nowMs, application) ?: ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -251,7 +251,7 @@ class ProStatusManager @Inject constructor( } launch { manageOtherPeoplePro() } - launch { manageProDetailsRefreshScheduling() } + launch { manageProStatusRefreshScheduling() } launch { manageCurrentProProofRevocation() } launch { postProLaunchStatus @@ -312,7 +312,7 @@ class ProStatusManager @Inject constructor( } @OptIn(FlowPreview::class) - private suspend fun manageProDetailsRefreshScheduling() { + private suspend fun manageProStatusRefreshScheduling() { postProLaunchStatus .collectLatest { postLaunch -> if (postLaunch) { @@ -327,7 +327,7 @@ class ProStatusManager @Inject constructor( .distinctUntilChanged() .map { "ProAccessExpiry in config changes" }, - proDetailsRepository.get().loadState + proStatusRepository.get().loadState .mapNotNull { it.lastUpdated?.first?.expiry } .distinctUntilChanged() .transformLatest { expiry -> @@ -357,13 +357,13 @@ class ProStatusManager @Inject constructor( .collect { refreshReason -> Log.d( DebugLogGroup.PRO_SUBSCRIPTION.label, - "Scheduling ProDetails fetch due to: $refreshReason" + "Scheduling ProStatus fetch due to: $refreshReason" ) - proDetailsRepository.get().requestRefresh(force = true) + proStatusRepository.get().requestRefresh(force = true) } } else { - FetchProDetailsWorker.cancel(application) + FetchProStatusWorker.cancel(application) } } } @@ -528,8 +528,8 @@ class ProStatusManager @Inject constructor( configs.userProfile.setProBadge(true) } - // refresh the pro details - proDetailsRepository.get().requestRefresh(force = true) + // refresh the pro status + proStatusRepository.get().requestRefresh(force = true) } is ProApiResponse.Failure -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt similarity index 77% rename from app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt index 02e4124d6f..88ee3987c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDetailsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt @@ -17,7 +17,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.dependencies.ManagerScope -import network.loki.messenger.libsession_util.pro.GetProDetailsResponse +import network.loki.messenger.libsession_util.pro.GetProStatusResponse import org.thoughtcrime.securesms.pro.db.ProDatabase import org.thoughtcrime.securesms.util.NetworkConnectivity import java.time.Instant @@ -25,7 +25,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class ProDetailsRepository @Inject constructor( +class ProStatusRepository @Inject constructor( private val application: Application, private val db: ProDatabase, private val snodeClock: SnodeClock, @@ -35,41 +35,41 @@ class ProDetailsRepository @Inject constructor( private val networkConnectivity: NetworkConnectivity, ) { sealed interface LoadState { - val lastUpdated: Pair? + val lastUpdated: Pair? data object Init : LoadState { - override val lastUpdated: Pair? + override val lastUpdated: Pair? get() = null } data class Loading( - override val lastUpdated: Pair?, + override val lastUpdated: Pair?, val waitingForNetwork: Boolean ) : LoadState - data class Loaded(override val lastUpdated: Pair) : LoadState - data class Error(override val lastUpdated: Pair?) : LoadState + data class Loaded(override val lastUpdated: Pair) : LoadState + data class Error(override val lastUpdated: Pair?) : LoadState } val loadState: StateFlow = loginStateRepository.flowWithLoggedInState { combine( - FetchProDetailsWorker.watch(application) + FetchProStatusWorker.watch(application) .map { it.state } .distinctUntilChanged(), networkConnectivity.networkAvailable, - db.proDetailsChangeNotification + db.proStatusChangeNotification .onStart { emit(Unit) } - .map { db.getProDetailsAndLastUpdated() } + .map { db.getProStatusAndLastUpdated() } ) { state, isOnline, last -> when (state) { WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> LoadState.Loading(last, waitingForNetwork = !isOnline) WorkInfo.State.RUNNING -> LoadState.Loading(last, waitingForNetwork = false) WorkInfo.State.SUCCEEDED -> { if (last != null) { - Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro details from backend") + Log.d(DebugLogGroup.PRO_DATA.label, "Successfully fetched Pro status from backend") LoadState.Loaded(last) } else { // This should never happen, but just in case... @@ -84,7 +84,7 @@ class ProDetailsRepository @Inject constructor( /** - * Requests a fresh of current user's pro details. By default, if last update is recent enough, + * Requests a fresh of current user's pro status. By default, if last update is recent enough, * no network request will be made. If [force] is true, a network request will be * made regardless of the freshness of the last update. */ @@ -98,12 +98,12 @@ class ProDetailsRepository @Inject constructor( if (!force && (currentState is LoadState.Loading || currentState is LoadState.Loaded) && currentState.lastUpdated?.second?.plusSeconds(MIN_UPDATE_INTERVAL_SECONDS) ?.isAfter(snodeClock.currentTime()) == true) { - Log.d(DebugLogGroup.PRO_DATA.label, "Pro details are fresh enough, skipping refresh") + Log.d(DebugLogGroup.PRO_DATA.label, "Pro status are fresh enough, skipping refresh") return } - Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro details from server") - FetchProDetailsWorker.schedule(application, ExistingWorkPolicy.REPLACE) + Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling fetch of Pro status from server") + FetchProStatusWorker.schedule(application, ExistingWorkPolicy.REPLACE) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt similarity index 61% rename from app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt rename to app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt index 78cc845352..03c15f3de1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt @@ -4,27 +4,26 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import network.loki.messenger.libsession_util.pro.BackendRequests -import network.loki.messenger.libsession_util.pro.GetProDetailsResponse +import network.loki.messenger.libsession_util.pro.GetProStatusResponse import network.loki.messenger.libsession_util.pro.ProRequest import org.session.libsession.network.SnodeClock -class GetProDetailsApi @AssistedInject constructor( +class GetProStatusApi @AssistedInject constructor( private val snodeClock: SnodeClock, @Assisted private val masterPrivateKey: ByteArray, deps: ProApiDependencies, -) : ProApi(deps) { +) : ProApi(deps) { override fun buildProRequest(): ProRequest = - BackendRequests.buildGetProDetailsRequest( + BackendRequests.buildGetProStatusRequest( masterPrivateKey = masterPrivateKey, nowSeconds = snodeClock.currentTimeMillis() / 1000, - count = 10, ) - override fun parseResponse(json: String): GetProDetailsResponse = - BackendRequests.parsePaymentDetailsResponse(json) + override fun parseResponse(json: String): GetProStatusResponse = + BackendRequests.parseProStatusResponse(json) @AssistedFactory interface Factory { - fun create(masterPrivateKey: ByteArray): GetProDetailsApi + fun create(masterPrivateKey: ByteArray): GetProStatusApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 616f71593f..98e3b910f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.json.Json import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper -import network.loki.messenger.libsession_util.pro.GetProDetailsResponse +import network.loki.messenger.libsession_util.pro.GetProStatusResponse import org.thoughtcrime.securesms.pro.api.ProRevocations import org.thoughtcrime.securesms.util.asSequence import java.time.Instant @@ -136,28 +136,28 @@ class ProDatabase @Inject constructor( } } - private val mutableProDetailsChangeNotification = MutableSharedFlow( + private val mutableProStatusChangeNotification = MutableSharedFlow( extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - val proDetailsChangeNotification: SharedFlow get() = mutableProDetailsChangeNotification + val proStatusChangeNotification: SharedFlow get() = mutableProStatusChangeNotification - fun getProDetailsAndLastUpdated(): Pair? { + fun getProStatusAndLastUpdated(): Pair? { return readableDatabase.query(""" SELECT name, value FROM pro_state WHERE name IN (?, ?) - """, arrayOf(STATE_PRO_DETAILS, STATE_PRO_DETAILS_UPDATED_AT)).use { cursor -> - var details: GetProDetailsResponse? = null + """, arrayOf(STATE_PRO_STATUS, STATE_PRO_STATUS_UPDATED_AT)).use { cursor -> + var details: GetProStatusResponse? = null var updatedAt: Instant? = null while (cursor.moveToNext()) { when (val name = cursor.getString(0)) { // Tolerate a stale/incompatible cached blob (e.g. an older shape): drop it and let // the next fetch repopulate, rather than throwing. - STATE_PRO_DETAILS -> details = - runCatching { json.decodeFromString(cursor.getString(1)) }.getOrNull() - STATE_PRO_DETAILS_UPDATED_AT -> updatedAt = Instant.ofEpochMilli(cursor.getString(1).toLong()) + STATE_PRO_STATUS -> details = + runCatching { json.decodeFromString(cursor.getString(1)) }.getOrNull() + STATE_PRO_STATUS_UPDATED_AT -> updatedAt = Instant.ofEpochMilli(cursor.getString(1).toLong()) else -> error("Unexpected state name $name") } } @@ -170,22 +170,22 @@ class ProDatabase @Inject constructor( } } - fun updateProDetails(proDetails: GetProDetailsResponse, updatedAt: Instant) { + fun updateProStatus(proStatus: GetProStatusResponse, updatedAt: Instant) { val changes = writableDatabase.compileStatement(""" INSERT INTO pro_state (name, value) VALUES (?, ?), (?, ?) ON CONFLICT DO UPDATE SET value=excluded.value WHERE value != excluded.value """).use { stmt -> - stmt.bindString(1, STATE_PRO_DETAILS) - stmt.bindString(2, json.encodeToString(proDetails)) - stmt.bindString(3, STATE_PRO_DETAILS_UPDATED_AT) + stmt.bindString(1, STATE_PRO_STATUS) + stmt.bindString(2, json.encodeToString(proStatus)) + stmt.bindString(3, STATE_PRO_STATUS_UPDATED_AT) stmt.bindString(4, updatedAt.toEpochMilli().toString()) stmt.executeUpdateDelete() } if (changes > 0) { - mutableProDetailsChangeNotification.tryEmit(Unit) + mutableProStatusChangeNotification.tryEmit(Unit) } } @@ -196,8 +196,8 @@ class ProDatabase @Inject constructor( private const val STATE_NAME_LAST_TICKET = "last_ticket" - private const val STATE_PRO_DETAILS = "pro_details" - private const val STATE_PRO_DETAILS_UPDATED_AT = "pro_details_updated_at" + private const val STATE_PRO_STATUS = "pro_status" + private const val STATE_PRO_STATUS_UPDATED_AT = "pro_status_updated_at" private const val ROTATING_KEY_VALIDITY_DAYS = 15 From f530e3806fe11721105313c1aba999930bc7c138 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 01:55:37 -0300 Subject: [PATCH 14/20] Pro plan display: hold (count, unit) end-to-end + generic plan cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render every plan period generically from libsession's parsed (count, unit) instead of fixed month buckets, and carry (count, unit) throughout instead of a re-parsed slug: - ProStatus.Active.duration is a ProPlanPeriod(count, unit); ProDataMapper builds it from the glue's structured (planCount, planUnit) — no slug regex. - SKU catalog (ProSubscriptionDuration) holds (count, unit) per opaque store slug. The annual SKU is (1, YEAR) to match the backend's "1y" code (ProPlan.TwelveMonth), never canonicalized, so current-plan matching works. - Choose-plan cards are one generic pair of strings — "{plan_length} - {monthly_price} / month" and "{price} billed every {plan_length}" — replacing the six fixed proPrice*/proBilled* keys; {plan_length} comes from the ICU formatter. - DateUtils: MONTH/YEAR added to the plan-length formatter; helper to resolve a not-yet-synced Crowdin key with an English fallback. New Crowdin keys (to add upstream): proPlanLifetime, proPlanPricePerMonth, proPlanBilledEvery. Retire: proPrice{One,Three,Twelve}Month(s), proBilled{Monthly,Quarterly,Annually}. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../libsession/utilities/StringSubKeys.kt | 1 + .../prosettings/ProSettingsViewModel.kt | 125 +++++++++--------- .../prosettings/chooseplan/ChoosePlan.kt | 38 ++---- .../chooseplan/ChoosePlanNonOriginating.kt | 8 +- .../securesms/pro/ProDataMapper.kt | 31 +++-- .../thoughtcrime/securesms/pro/ProStatus.kt | 8 +- .../securesms/pro/ProStatusManager.kt | 12 +- .../subscription/ProSubscriptionDuration.kt | 58 +++++++- .../thoughtcrime/securesms/util/DateUtils.kt | 42 ++++++ 9 files changed, 201 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index df2750ecf7..8624f8a9da 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -50,6 +50,7 @@ object StringSubstitutionConstants { const val BUILD_VARIANT_KEY: StringSubKey = "build_variant" const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" + const val PLAN_LENGTH_KEY: StringSubKey = "plan_length" const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length" const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length" const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index e52ebe2acc..c73440fb32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent -import android.icu.util.MeasureUnit import android.widget.Toast import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -38,6 +37,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PERCENT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY @@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.pro.ProStatus import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData import org.thoughtcrime.securesms.pro.isFromAnotherPlatform +import org.thoughtcrime.securesms.pro.subscription.ProPlanPeriod import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager @@ -625,16 +626,12 @@ class ProSettingsViewModel @AssistedInject constructor( if(currentSubscription is ProStatus.Active){ val newSubscriptionExpiryString = currentSubscription.renewingAtFormatted() - val currentSubscriptionDuration = DateUtils.getLocalisedTimeDuration( - context = context, - amount = currentSubscription.duration.duration.months, - unit = MeasureUnit.MONTH + val currentSubscriptionDuration = DateUtils.getLocalisedProPlanLength( + context, currentSubscription.duration ) - val selectedSubscriptionDuration = DateUtils.getLocalisedTimeDuration( - context = context, - amount = selectedPlan.durationType.duration.months, - unit = MeasureUnit.MONTH + val selectedSubscriptionDuration = DateUtils.getLocalisedProPlanLength( + context, selectedPlan.durationType.period ) _dialogState.update { @@ -810,10 +807,12 @@ class ProSettingsViewModel @AssistedInject constructor( } private suspend fun getSubscriptionPlans(subType: ProStatus): List { - val isActive = subType is ProStatus.Active - val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS - val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS - val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH + // The active plan's raw (count, unit), or null when not subscribed. We mark/disable a catalog SKU + // as the user's "current" plan by matching this period against the SKU's own (count, unit) — NOT + // by a fixed enum, so the unit is respected as transmitted. This is cosmetic and (count, unit) is + // not a guaranteed-unique key, so we degrade gracefully: a SKU is "current" iff its period equals + // the active plan's; if nothing matches (e.g. a "1y" plan vs a "12m" SKU), nothing is marked. + val activePeriod = (subType as? ProStatus.Active)?.duration // get prices from the subscription provider val prices = subscriptionCoordinator.getCurrentManager().getSubscriptionPrices() @@ -822,61 +821,61 @@ class ProSettingsViewModel @AssistedInject constructor( val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) + // The 1-month plan's per-month price is the discount baseline for the longer plans. val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO - val plan12Months = data12Month?.let { - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, it.perMonthText) - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, it.totalText) - .format().toString(), - selected = currentPlan12Months || subType !is ProStatus.Active, // selected if our active sub is 12 month, or as a default for non pro or renew - currentPlan = currentPlan12Months, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, - badges = buildList { - if (currentPlan12Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) - discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan12Months)?.let(this::add) - } - ) - } + // One generic card per SKU (longest first). The period label ("3 months"/"1 year") comes from the + // locale formatter, so a new SKU needs no new strings; the 1-month card naturally carries no + // discount badge (its per-month price equals the baseline, so the computed discount is 0). + return listOfNotNull( + data12Month?.let { buildProPlanCard(ProSubscriptionDuration.TWELVE_MONTHS, it, baseline, subType, activePeriod) }, + data3Month?.let { buildProPlanCard(ProSubscriptionDuration.THREE_MONTHS, it, baseline, subType, activePeriod) }, + data1Month?.let { buildProPlanCard(ProSubscriptionDuration.ONE_MONTH, it, baseline, subType, activePeriod) }, + ) + } - val plan3Months = data3Month?.let { - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, it.perMonthText) - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, it.totalText) - .format().toString(), - selected = currentPlan3Months, - currentPlan = currentPlan3Months, - durationType = ProSubscriptionDuration.THREE_MONTHS, - badges = buildList { - if (currentPlan3Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) - discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan3Months)?.let(this::add) - } + /** + * Build one choose-plan card for a catalog [sku] from its [data] prices. Both card strings are + * generic over the SKU's (count, unit): the title is "{plan_length} - {monthly_price} / month" and + * the subtitle "{price} billed every {plan_length}", with `plan_length` rendered by the locale + * formatter — so no per-duration strings. [baseline] is the 1-month per-month price for the discount + * badge; a SKU is "current" iff its period equals the active plan's [activePeriod]. + */ + private fun buildProPlanCard( + sku: ProSubscriptionDuration, + data: PriceDisplayData, + baseline: BigDecimal, + subType: ProStatus, + activePeriod: ProPlanPeriod?, + ): ProPlan { + val isCurrent = activePeriod == sku.period + val planLength = DateUtils.getLocalisedProPlanLength(context, sku.period) + return ProPlan( + title = Phrase.from( + DateUtils.proStringTemplateOrFallback( + context, "proPlanPricePerMonth", "{plan_length} - {monthly_price} / month" + ) ) - } - - val plan1Month = data1Month?.let { - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, it.perMonthText) - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, it.totalText) - .format().toString(), - selected = currentPlan1Month, - currentPlan = currentPlan1Month, - durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if (currentPlan1Month) listOf(ProPlanBadge(context.getString(R.string.currentBilling))) else emptyList() - // no discount on the baseline 1 month... + .put(PLAN_LENGTH_KEY, planLength) + .put(MONTHLY_PRICE_KEY, data.perMonthText) + .format().toString(), + subtitle = Phrase.from( + DateUtils.proStringTemplateOrFallback( + context, "proPlanBilledEvery", "{price} billed every {plan_length}" + ) ) - } - - return listOfNotNull(plan12Months, plan3Months, plan1Month) + .put(PRICE_KEY, data.totalText) + .put(PLAN_LENGTH_KEY, planLength) + .format().toString(), + // The longest plan is the default selection for a non-subscriber / renew flow. + selected = isCurrent || (subType !is ProStatus.Active && sku == ProSubscriptionDuration.TWELVE_MONTHS), + currentPlan = isCurrent, + durationType = sku, + badges = buildList { + if (isCurrent) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, perMonthUnits = data.perMonthUnits, showTooltip = isCurrent)?.let(::add) + }, + ) } private data class PriceDisplayData(val perMonthUnits: BigDecimal, val perMonthText: String, val totalText: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt index 345f370a8b..71dd6553f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.preferences.prosettings.chooseplan -import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -56,8 +55,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ENTITY_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.thoughtcrime.securesms.preferences.prosettings.BaseProSettingsScreen import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel @@ -116,11 +113,8 @@ fun ChoosePlan( is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatesAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put( - CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( - context = context, - amount = planData.proStatus.duration.duration.months, - unit = MeasureUnit.MONTH - ) + CURRENT_PLAN_LENGTH_KEY, + DateUtils.getLocalisedProPlanLength(context, planData.proStatus.duration) ) .put(DATE_KEY, planData.proStatus.renewingAtFormatted()) .format() @@ -490,12 +484,8 @@ private fun PreviewUpdatePlan( enableButton = true, plans = listOf( ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") - .format().toString(), + title = "1 year - $3.99 / month", + subtitle = "$47.99 billed every 1 year", selected = false, currentPlan = false, durationType = ProSubscriptionDuration.TWELVE_MONTHS, @@ -504,30 +494,22 @@ private fun PreviewUpdatePlan( ), ), ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") - .format().toString(), + title = "3 months - $4.99 / month", + subtitle = "$14.99 billed every 3 months", selected = true, currentPlan = true, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, + durationType = ProSubscriptionDuration.THREE_MONTHS, badges = listOf( ProPlanBadge("Current Plan"), ProPlanBadge("20% Off", "This is a tooltip"), ), ), ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") - .format().toString(), + title = "1 month - $5.99 / month", + subtitle = "$5.99 billed every 1 month", selected = false, currentPlan = false, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, + durationType = ProSubscriptionDuration.ONE_MONTH, badges = emptyList(), ), ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index b52106a833..aaf619a205 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.preferences.prosettings.chooseplan -import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -50,11 +49,8 @@ fun ChoosePlanNonOriginating( is ProStatus.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatedAutoShort)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( - context = context, - amount = subscription.duration.duration.months, - unit = MeasureUnit.MONTH - )) + .put(CURRENT_PLAN_LENGTH_KEY, + DateUtils.getLocalisedProPlanLength(context, subscription.duration)) .put(DATE_KEY, subscription.renewingAtFormatted()) .format() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index ea6df67482..f8e5879cdb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.pro import android.content.Context import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY import network.loki.messenger.libsession_util.pro.GetProStatusResponse +import network.loki.messenger.libsession_util.pro.ProPaymentItem +import org.thoughtcrime.securesms.pro.subscription.ProPlanPeriod +import org.thoughtcrime.securesms.pro.subscription.ProPlanUnit import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import java.time.Duration import java.time.Instant @@ -31,9 +34,18 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { val renewingAtMs = expiryMs - gracePeriod.toMillis() val renewingAt = Instant.ofEpochMilli(renewingAtMs) val providerData = providerMetadata(paymentItem.paymentProvider, context) - val duration = paymentItem.plan.toSubscriptionDuration() + val duration = paymentItem.toProPlanPeriod() val refundInProgress = refundRequested != null + // Correctness guard (Delta #14): a lifetime plan is NOT a renewing/expiring subscription and + // has no renewal/expiry date to render. A genuine lifetime carries no account `expiry`, so it + // already falls through the `expiry ?: return NeverSubscribed` short-circuit above; this + // explicit check additionally guarantees a lifetime plan can never be presented via the + // AutoRenewing/Expiring ("renews/expires on {date}") paths. NOTE: there is no dedicated + // always-on Active state yet, so lifetime is currently surfaced as not-subscribed — a + // follow-up should add an `Active.Lifetime` state with no date. + if (duration.isLifetime) return ProStatus.NeverSubscribed + if (autoRenewing) { ProStatus.Active.AutoRenewing( renewingAt = renewingAt, @@ -68,13 +80,16 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { } /** - * Map an opaque billing-period slug (spec: `N` + unit `d`/`w`/`m`/`y`; canonical `"1m"`/`"3m"`/`"1y"`) - * to the app's subscription duration. Unknown/future codes fall back to the one-month presentation. + * The billing period as a (count, unit) [ProPlanPeriod]. libsession parses the wire `plan` grammar + * (pro-wire-protocol.md §1 / Delta #14) into `{count, unit}` and the android glue hands it to us as the + * structured pair `planCount` + `planUnit` (see libsession-util-android `pro_backend.cpp` + * `plan_unit_to_string`). We keep it as (count, unit) verbatim — the unit is PRESERVED as transmitted + * (never canonicalized), so a NEW period ("6m", "1w", "2y") needs ZERO code change to render. An + * unrecognized unit name shouldn't occur (closed grammar); we fall back to a one-month period. */ -fun String.toSubscriptionDuration(): ProSubscriptionDuration = when (this) { - "1y" -> ProSubscriptionDuration.TWELVE_MONTHS - "3m" -> ProSubscriptionDuration.THREE_MONTHS - else -> ProSubscriptionDuration.ONE_MONTH // "1m" + fallback +fun ProPaymentItem.toProPlanPeriod(): ProPlanPeriod { + val unit = ProPlanUnit.fromWireName(planUnit) ?: ProPlanUnit.MONTH + return ProPlanPeriod(planCount, unit) } fun PaymentProviderMetadata.isFromAnotherPlatform(): Boolean { @@ -109,7 +124,7 @@ val previewAppleMetaData = PaymentProviderMetadata( val previewAutoRenewingApple = ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.THREE_MONTHS, + duration = ProSubscriptionDuration.THREE_MONTHS.period, providerData = previewAppleMetaData, quickRefundExpiry = Instant.now() + Duration.ofDays(14), refundInProgress = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index 0889ccf168..9e9e678e6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.pro import network.loki.messenger.BuildConfig -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.ProPlanPeriod import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import java.time.Instant @@ -11,14 +11,14 @@ sealed interface ProStatus{ sealed interface Active: ProStatus{ val renewingAt: Instant //this takes into account the expiry and the grace period - val duration: ProSubscriptionDuration + val duration: ProPlanPeriod // the backend's raw (count, unit) — rendered generically, never bucketed val providerData: PaymentProviderMetadata val quickRefundExpiry: Instant? val refundInProgress: Boolean data class AutoRenewing( override val renewingAt: Instant, - override val duration: ProSubscriptionDuration, + override val duration: ProPlanPeriod, override val providerData: PaymentProviderMetadata, override val quickRefundExpiry: Instant?, override val refundInProgress: Boolean, @@ -27,7 +27,7 @@ sealed interface ProStatus{ data class Expiring( override val renewingAt: Instant, - override val duration: ProSubscriptionDuration, + override val duration: ProPlanPeriod, override val providerData: PaymentProviderMetadata, override val quickRefundExpiry: Instant?, override val refundInProgress: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 997adb6236..f6f11d1571 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -154,7 +154,7 @@ class ProStatusManager @Inject constructor( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.THREE_MONTHS, + duration = ProSubscriptionDuration.THREE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, @@ -163,7 +163,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE_REFUNDING -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.THREE_MONTHS, + duration = ProSubscriptionDuration.THREE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = true, @@ -172,7 +172,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(2), - duration = ProSubscriptionDuration.TWELVE_MONTHS, + duration = ProSubscriptionDuration.TWELVE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false @@ -180,7 +180,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(40), - duration = ProSubscriptionDuration.TWELVE_MONTHS, + duration = ProSubscriptionDuration.TWELVE_MONTHS.period, providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false @@ -188,7 +188,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.ONE_MONTH, + duration = ProSubscriptionDuration.ONE_MONTH.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, @@ -197,7 +197,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_APPLE -> ProStatus.Active.Expiring( renewingAt = Instant.now() + Duration.ofDays(2), - duration = ProSubscriptionDuration.ONE_MONTH, + duration = ProSubscriptionDuration.ONE_MONTH.period, providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index e0b2af4b08..c101f14db8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -1,12 +1,56 @@ package org.thoughtcrime.securesms.pro.subscription -import java.time.Period +/** + * Billing-period unit, mirroring libsession's `ProPlanUnit` (pro-wire-protocol.md §1 / Delta #14). + * The unit is PRESERVED exactly as transmitted and is never canonicalized (e.g. "12m" and "1y" are + * the same real duration but distinct values). A new unit renders generically via the locale formatter. + */ +enum class ProPlanUnit { + SECOND, DAY, WEEK, MONTH, YEAR, LIFETIME; -enum class ProSubscriptionDuration(val duration: Period, val id: String) { - ONE_MONTH(Period.ofMonths(1), "session-pro-1-month"), - THREE_MONTHS(Period.ofMonths(3), "session-pro-3-months"), - TWELVE_MONTHS(Period.ofMonths(12), "session-pro-12-months") + companion object { + /** + * Map the glue's lowercase unit name (see libsession-util-android `plan_unit_to_string`, which + * mirrors the nodejs glue) to a [ProPlanUnit]. Returns null for an unrecognized name — libsession's + * closed grammar means that shouldn't happen, so callers can pick a benign fallback. + */ + fun fromWireName(name: String): ProPlanUnit? = when (name) { + "second" -> SECOND + "day" -> DAY + "week" -> WEEK + "month" -> MONTH + "year" -> YEAR + "lifetime" -> LIFETIME + else -> null + } + } } -fun ProSubscriptionDuration.getById(id: String): ProSubscriptionDuration? = - ProSubscriptionDuration.entries.find { it.id == id } +/** + * A parsed billing period: [count] copies of [unit]. Invariant (matching libsession): `count == 0` + * iff `unit == LIFETIME`; for the periodic units [count] is >= 1. This is the app's own small value + * type used both for the active-plan display (from the backend) and to express the fixed purchase SKUs. + */ +data class ProPlanPeriod(val count: Int, val unit: ProPlanUnit) { + val isLifetime: Boolean get() = unit == ProPlanUnit.LIFETIME + + companion object { + val LIFETIME = ProPlanPeriod(0, ProPlanUnit.LIFETIME) + } +} + +/** + * The FIXED store SKU catalog: an opaque store slug [id] (used FORWARD ONLY — load catalog, initiate + * purchase, price lookup) plus its [period] as a (count, unit). This is the purchase/billing catalog + * and is intentionally a closed list; it is NEVER reverse-mapped from the active backend plan (that + * renders generically from the backend's own (count, unit) — see [ProPlanPeriod]). + */ +enum class ProSubscriptionDuration(val period: ProPlanPeriod, val id: String) { + ONE_MONTH(ProPlanPeriod(1, ProPlanUnit.MONTH), "session-pro-1-month"), + THREE_MONTHS(ProPlanPeriod(3, ProPlanUnit.MONTH), "session-pro-3-months"), + // The annual SKU's period MUST equal what the backend reports for it — ProPlan.TwelveMonth is the + // code "1y" (session-pro-backend base.py), which libsession parses to (1, YEAR) with no + // canonicalization. So this is (1, YEAR), not (12, MONTH); otherwise current-plan matching (period + // equality against the active backend plan) would never match this SKU. + TWELVE_MONTHS(ProPlanPeriod(1, ProPlanUnit.YEAR), "session-pro-12-months") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index d1ba385308..377465c49e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -7,6 +7,8 @@ import android.icu.util.MeasureUnit import android.text.format.DateFormat import dagger.hilt.android.qualifiers.ApplicationContext import network.loki.messenger.R +import org.thoughtcrime.securesms.pro.subscription.ProPlanPeriod +import org.thoughtcrime.securesms.pro.subscription.ProPlanUnit import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.TextSecurePreferences.Companion.DATE_FORMAT_PREF import org.session.libsession.utilities.TextSecurePreferences.Companion.TIME_FORMAT_PREF @@ -305,6 +307,46 @@ class DateUtils @Inject constructor( } } + /** + * Render a Pro plan length from the backend's raw (count, unit) [ProPlanPeriod], GENERICALLY, + * via the OS locale formatter — so a new period ("6m", "1w", "2y") renders with zero code change. + * + * - Periodic units (second/day/week/month/year) map to the matching ICU [MeasureUnit] and go + * through [getLocalisedTimeDuration] (locale-aware + capitalised); MONTH and YEAR are supported. + * - LIFETIME is NOT a duration: it renders the localized `proPlanLifetime` string when that key + * exists (base English counts), else the English fallback "Lifetime". We gate on the resource + * existing (same pattern as the pro_provider_* / pro_error_* strings) so it works before the + * Crowdin sync adds the key. Callers should not normally pass a lifetime plan here (it has no + * renewal/expiry surface), but we handle it safely rather than emitting "0 months". + */ + fun getLocalisedProPlanLength(context: Context, period: ProPlanPeriod): String { + if (period.unit == ProPlanUnit.LIFETIME) { + val resId = context.resources.getIdentifier("proPlanLifetime", "string", context.packageName) + return if (resId != 0) context.getString(resId) else "Lifetime" + } + + val measureUnit = when (period.unit) { + ProPlanUnit.SECOND -> MeasureUnit.SECOND + ProPlanUnit.DAY -> MeasureUnit.DAY + ProPlanUnit.WEEK -> MeasureUnit.WEEK + ProPlanUnit.MONTH -> MeasureUnit.MONTH + ProPlanUnit.YEAR -> MeasureUnit.YEAR + ProPlanUnit.LIFETIME -> return "Lifetime" // handled above; keeps the `when` exhaustive + } + return getLocalisedTimeDuration(context, period.count, measureUnit) + } + + /** + * Resolve a Crowdin string template by resource [name], or fall back to [englishFallback] when the + * key isn't in the (generated) resources yet — the same not-yet-synced gate as the + * `proPlanLifetime` lookup above, so we can reference new Pro keys before the Crowdin sync adds + * them. Returns a CharSequence suitable for `Phrase.from(...)`. + */ + fun proStringTemplateOrFallback(context: Context, name: String, englishFallback: String): CharSequence { + val resId = context.resources.getIdentifier(name, "string", context.packageName) + return if (resId != 0) context.getText(resId) else englishFallback + } + // Format a given timestamp with a specific pattern fun formatTime(timestamp: Long, pattern: String, locale: Locale = Locale.getDefault()): String { val formatter = DateTimeFormatter.ofPattern(pattern, locale) From 2de32b0c84e7cde0fc0f286225a5b9e74a7f9020 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 02:29:10 -0300 Subject: [PATCH 15/20] Anchor Pro discount baseline to the shortest available plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive the per-month discount baseline from the highest per-month price among the available plans (the shortest-duration plan) instead of assuming the 1-month SKU exists. If the monthly plan is ever dropped from the catalog, the discounts still anchor correctly. The baseline plan itself gets 0% and no badge via discountBadge() (already generic — no hardcoded period). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../preferences/prosettings/ProSettingsViewModel.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index c73440fb32..82d96db2fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -821,8 +821,11 @@ class ProSettingsViewModel @AssistedInject constructor( val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) - // The 1-month plan's per-month price is the discount baseline for the longer plans. - val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO + // Discount baseline = the highest per-month price among the available plans — i.e. the shortest + // plan, since shorter plans cost more per month. Don't assume the 1-month SKU exists; whichever + // plan equals the baseline gets 0% and no badge via discountBadge(). + val baseline = listOfNotNull(data1Month, data3Month, data12Month) + .maxOfOrNull { it.perMonthUnits } ?: BigDecimal.ZERO // One generic card per SKU (longest first). The period label ("3 months"/"1 year") comes from the // locale formatter, so a new SKU needs no new strings; the 1-month card naturally carries no From 148e536e5b2a260358b4e825a76d2d371edec5ad Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 14:51:04 -0300 Subject: [PATCH 16/20] =?UTF-8?q?Drop=20stale=20Delta-N=20spec=20citations?= =?UTF-8?q?;=20reference=20current=20=C2=A7=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire spec (docs/pro-wire-protocol.md) dropped its Delta-N change-history annotations, so every 'Delta #N' citation was a dangling reference. Reworded each to the current section (envelope -> §5/§5.1/§5.2, plan grammar -> §1, get_pro_status -> §3.4) or dropped the parenthetical where there is no section. Verified all remaining pro-wire-protocol § references resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt | 2 +- .../main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt | 4 ++-- .../thoughtcrime/securesms/pro/ProProofGenerationWorker.kt | 2 +- .../java/org/thoughtcrime/securesms/pro/ProStatusManager.kt | 4 ++-- .../main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt | 2 +- .../securesms/pro/subscription/ProSubscriptionDuration.kt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt index a2f0c0f0a4..6a34c214ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt @@ -6,7 +6,7 @@ import network.loki.messenger.libsession_util.pro.BackendRequests /** * App-facing per-provider metadata: the human-readable display names — client-owned i18n now that - * libsession no longer supplies them (Delta #10) — plus the support/management URLs, which are still + * libsession no longer supplies them — plus the support/management URLs, which are still * libsession's (fetched via [BackendRequests.providerUrls] by the provider slug). Replaces the removed * libsession `PaymentProviderMetadata` struct. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index f8e5879cdb..8ec72c9188 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -37,7 +37,7 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { val duration = paymentItem.toProPlanPeriod() val refundInProgress = refundRequested != null - // Correctness guard (Delta #14): a lifetime plan is NOT a renewing/expiring subscription and + // Correctness guard (plan grammar, §1): a lifetime plan is NOT a renewing/expiring subscription and // has no renewal/expiry date to render. A genuine lifetime carries no account `expiry`, so it // already falls through the `expiry ?: return NeverSubscribed` short-circuit above; this // explicit check additionally guarantees a lifetime plan can never be presented via the @@ -81,7 +81,7 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { /** * The billing period as a (count, unit) [ProPlanPeriod]. libsession parses the wire `plan` grammar - * (pro-wire-protocol.md §1 / Delta #14) into `{count, unit}` and the android glue hands it to us as the + * (pro-wire-protocol.md §1) into `{count, unit}` and the android glue hands it to us as the * structured pair `planCount` + `planUnit` (see libsession-util-android `pro_backend.cpp` * `plan_unit_to_string`). We keep it as (count, unit) verbatim — the unit is PRESERVED as transmitted * (never canonicalized), so a NEW period ("6m", "1w", "2y") needs ZERO code change to render. An diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt index db33e3aea5..20fc4c3dd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -75,7 +75,7 @@ class ProProofGenerationWorker @AssistedInject constructor( ), ) ).successOrThrow() - // Delta #12 invariant: an `ok` proof response always carries the proof. + // §5.2 invariant: an `ok` proof response always carries the proof. val proof = requireNotNull(response.proof) { "generate-proof returned ok without a proof" } configFactory.withMutableUserConfigs { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index f6f11d1571..c3ea603a78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -517,7 +517,7 @@ class ProStatusManager @Inject constructor( configFactory.get().withMutableUserConfigs { configs -> configs.userProfile.setProConfig( ProConfig( - // Delta #12 invariant: an `ok` add-payment always carries a proof + // §5.2 invariant: an `ok` add-payment always carries a proof // (a re-claim of an already-redeemed payment now succeeds with one too). proProof = requireNotNull(paymentResponse.data.proof) { "add-payment returned ok without a proof" @@ -534,7 +534,7 @@ class ProStatusManager @Inject constructor( is ProApiResponse.Failure -> { Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' failure: $paymentResponse") - // Delta #12: `already_redeemed` is gone — a re-claim now returns ok + a proof + // §5.1: `already_redeemed` is gone — a re-claim now returns ok + a proof // (handled above). Retry transient failures (backend fault, or a payment the // backend hasn't ingested yet); everything else is a hard, non-retryable failure. if (paymentResponse.error.isRetryable) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 5ee0187eed..51496cb3f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -74,7 +74,7 @@ abstract class ProApi(private val deps: ProApiDependencies) } /** - * A failed Pro backend response (Delta #12). [status] is [ProResponseStatus.Fail] (client input / + * A failed Pro backend response (§5). [status] is [ProResponseStatus.Fail] (client input / * precondition) or [ProResponseStatus.Error] (backend fault, retryable). [errorCode] is the machine slug * (see [ProErrorCode]; null if libsession supplied none) that the UI maps to a localized string; [error] * is an English diagnostic — NOT user-facing (only shown when a slug has no translation), always safe to log. diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index c101f14db8..f540b0f9c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.pro.subscription /** - * Billing-period unit, mirroring libsession's `ProPlanUnit` (pro-wire-protocol.md §1 / Delta #14). + * Billing-period unit, mirroring libsession's `ProPlanUnit` (pro-wire-protocol.md §1). * The unit is PRESERVED exactly as transmitted and is never canonicalized (e.g. "12m" and "1y" are * the same real duration but distinct values). A new unit renders generically via the locale formatter. */ From 93b0808e5ce88af1b2fb9317c67d377270231b5b Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 23 Jul 2026 17:44:40 -0300 Subject: [PATCH 17/20] Fix doubled slash in Pro backend request path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pro backend URL is an okhttp HttpUrl whose toString() normalizes a bare host to a trailing "/", so "$baseUrl/${endpoint}" produced e.g. https://host//get_pro_status — a doubled slash in the inner onion-request path. Trim the trailing slash before appending the endpoint. (Cosmetic: the backend normalizes it, but it was a bug.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 51496cb3f3..79595fedbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt @@ -36,7 +36,9 @@ abstract class ProApi(private val deps: ProApiDependencies) val request = buildProRequest() return HttpRequest( method = "POST", - url = "$baseUrl/${request.endpoint}".toHttpUrl(), + // baseUrl is an HttpUrl.toString(), which normalizes a bare host to a trailing "/"; trim it + // so we don't emit a doubled slash (e.g. //get_pro_status) in the inner request path. + url = "${baseUrl.trimEnd('/')}/${request.endpoint}".toHttpUrl(), // Content-Type comes from libsession (the wire format is its contract with the backend); we relay it headers = mapOf( "Content-Type" to request.contentType From 3facdfdf0d66c11f64a0140f37d77f514ca5299b Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Jul 2026 09:50:12 +1000 Subject: [PATCH 18/20] Fixed build issues resulting from Pro changes --- .../thoughtcrime/securesms/pro/FetchProStatusWorker.kt | 7 +++++-- .../org/thoughtcrime/securesms/pro/db/ProDatabase.kt | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt index cecfc99aba..626d2ebced 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -84,8 +84,11 @@ class FetchProStatusWorker @AssistedInject constructor( ) configFactory.withMutableUserConfigs { configs -> - if (details.expiry != null) { - configs.userProfile.setProAccessExpiry(details.expiry.epochSecond) + // Capture into a local: `expiry` is a public API property from another module, so + // Kotlin can't smart-cast the nullable directly on the property access. + val expiry = details.expiry + if (expiry != null) { + configs.userProfile.setProAccessExpiry(expiry.epochSecond) } else { configs.userProfile.removeProAccessExpiry() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 98e3b910f5..0fab234496 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -13,7 +13,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.Database import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper import network.loki.messenger.libsession_util.pro.GetProStatusResponse -import org.thoughtcrime.securesms.pro.api.ProRevocations +import network.loki.messenger.libsession_util.pro.ProRevocationItem import org.thoughtcrime.securesms.util.asSequence import java.time.Instant import javax.inject.Inject @@ -50,7 +50,7 @@ class ProDatabase @Inject constructor( newTicket: Long, retainForSeconds: Long, now: Instant, - data: List + data: List ) { var changes = 0 // Memory-/storage-only local aging: keep each item until `seen + retain_for` (§4). @@ -68,8 +68,8 @@ class ProDatabase @Inject constructor( """ ).use { stmt -> for (item in data) { - stmt.bindString(1, item.revocationTag) - stmt.bindLong(2, item.effectiveFrom.epochSecond) + stmt.bindString(1, item.revocationTagHex) + stmt.bindLong(2, item.effectiveUnixTs) stmt.bindLong(3, retainUntil) changes += stmt.executeUpdateDelete() stmt.clearBindings() @@ -88,7 +88,7 @@ class ProDatabase @Inject constructor( } for (item in data) { - cache.put(item.revocationTag, Unit) + cache.put(item.revocationTagHex, Unit) } if (changes > 0) { From 2f0ceb7a85ddf5a5b5541b765f6c2aafa4b437b5 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 27 Jul 2026 16:41:20 +1000 Subject: [PATCH 19/20] Pro: fix purchase redemption, non-originating misclassification, and revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the Pro readiness review. - obfuscatedAccountId is the raw master pubkey hex, not sha256 of it. The backend dropped the hash (session-pro-backend 60ea9f6) and binds on byte equality, so every Play purchase was failing to redeem with unknown_payment. Both forms are 64 hex chars so it failed silently on value; the test now pins both superseded schemes as negative cases. - isFromAnotherPlatform() keys off the provider slug, not a localized display string. The pro_provider_* resources it read didn't exist, so "google_play" != "google" classified every Play subscriber as non-originating. Adds the 12 missing strings, replacing 8 orphaned camelCase ones — these still need upstreaming to Crowdin. - addProPayment() returns on success; without it a successful purchase ran the retry loop out and surfaced as Payment Error. The post-success refresh no longer re-enters the loop. - Keep contact proofs that are EXPIRED as well as VALID. proProofInfo is shared config and iOS/Desktop preserve them, so clearing here caused cross-platform ping-pong. Expiry is enforced at render, which makes the write-time clock unused. - Revocations: execute the last-ticket write (every poll re-fetched from 0), respect effective_ts in the cache, and clamp retry_in (<=0 -> 1 day, else [60s, 1 day]). - Smaller: SnodeClock instead of Instant.now() for the quick-refund window, obfuscatedProId no longer throws from a Play callback when logged out, dead PRO_BACKEND_DEV removed, and a duplicated revocation-poll scheduler deleted. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 6 +- app/build.gradle.kts | 12 +-- .../messages/ProfileUpdateHandler.kt | 32 +++++++- .../sending_receiving/GroupMessageHandler.kt | 4 +- .../MessageRequestResponseHandler.kt | 4 +- .../VisibleMessageHandler.kt | 3 - .../prosettings/ProSettingsViewModel.kt | 2 +- .../securesms/pro/PaymentProviderMetadata.kt | 7 ++ .../securesms/pro/PlayStoreAccountId.kt | 18 +++-- .../securesms/pro/ProDataMapper.kt | 20 +++-- .../thoughtcrime/securesms/pro/ProStatus.kt | 12 ++- .../securesms/pro/ProStatusManager.kt | 34 +++++--- .../pro/RevocationListPollingWorker.kt | 48 ++++++++++- .../securesms/pro/db/ProDatabase.kt | 17 +++- app/src/main/res/values/strings.xml | 34 +++++--- .../PlayStoreSubscriptionManager.kt | 28 +++++-- .../securesms/pro/PlayStoreAccountIdTest.kt | 81 ++++++++++++++++--- 17 files changed, 286 insertions(+), 76 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1833a6f11b..b3ab0d90b7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,7 +60,11 @@ In practice: - `PLAY_STORE_DISABLED`: gates store-specific behavior - `DEVICE`: identifies Android vs Huawei device environment - `PUSH_KEY_SUFFIX`: provider-specific push-key suffix -- `PRO_BACKEND_DEV`: currently the injected Pro backend configuration object + +The Pro backend configuration is **not** among them: its URL and signing pubkey come from +libsession (`BackendRequests.proBackendUrl()` / `proBackendPubKeyHex()`, assembled into +`ProBackendConfig` by `ProModule`), so changing the backend means releasing libsession, not editing +the build script. There is no client-side override. Manifest placeholders are also used to vary: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a5ccadb9e3..edb1ea4cb0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -144,12 +144,12 @@ android { buildConfigField("String", "USER_AGENT", "\"OWA\"") buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode") - buildConfigField("org.thoughtcrime.securesms.pro.ProBackendConfig", "PRO_BACKEND_DEV", """ - new org.thoughtcrime.securesms.pro.ProBackendConfig( - "https://pro.session.codes", - "479ffca8bcec7b4a0f0f7afe48b8a6d15635a8c7ff15ad16add05752c19414d4" - ) - """.trimIndent()) + // NOTE: there is deliberately no PRO_BACKEND_DEV buildConfigField here. The Pro backend URL + // and signing pubkey are single-sourced from libsession (`BackendRequests.proBackendUrl()` / + // `proBackendPubKeyHex()`, wired up in ProModule); a second copy in the build script is a + // launch trap, since it would keep shipping the dev URL and debug pubkey after libsession + // moved to production. If a client-side override is ever wanted, add it deliberately as an + // override *of* the libsession default, not as a parallel source of truth. testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments["clearPackageData"] = "true" diff --git a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt index 3cea2a6012..8d14866102 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/ProfileUpdateHandler.kt @@ -196,8 +196,10 @@ class ProfileUpdateHandler @Inject constructor( val profileUpdateTime: Instant?, ) { companion object { + // No clock parameter: deciding whether to KEEP a verified proof is time-independent + // (see the status check below). Expiry is applied at render, in + // RecipientRepository.resolveProStatus(), on SnodeClock time. fun create(content: SessionProtos.Content, - nowMills: Long, pro: DecodedPro?): Updates? { val profile: SessionProtos.LokiProfile val profilePicKey: ByteString? @@ -247,13 +249,35 @@ class ProfileUpdateHandler @Inject constructor( val proProofInfo: Conversation.ProProofInfo? val proFeatures: BitSet - if (pro?.status == ProProof.STATUS_VALID && - pro.proof != null && - pro.proof!!.expirySeconds > nowMills / 1000) { + // Keep the record for any proof libsession has cryptographically verified — VALID or + // EXPIRED — and let expiry be enforced at render instead of here. + // + // EXPIRED is "genuine but lapsed", never "unverified": ProProof::status checks the + // Pro backend signature, then the user signature, and only then expiry + // (LibSession-Util `src/session_protocol.cpp:154-174`), so a forged proof lands on + // STATUS_INVALID_PRO_BACKEND_SIGNATURE / STATUS_INVALID_USER_SIGNATURE and is + // discarded by the `else` below. Those two must keep clearing the record. + // + // Why we no longer drop an expired proof here: `proProofInfo` goes into + // convoInfoVolatile, which is SHARED config. A linked device (or iOS/Desktop, which + // both preserve expired proofs) may have received this contact's proof while it was + // still valid; clearing it here would sync the clear back and cause config + // ping-pong between platforms. Android was the only client discarding them. + // + // This cannot surface a Pro badge for an expired proof: RecipientRepository + // .resolveProStatus() drops expired and revoked proofs on SnodeClock time before any + // badge is computed, and it's the only writer of `Recipient.data.proData`. Do not + // "restore" an expiry check here on the assumption that render-time filtering is + // missing — it isn't; the comparison lives inside RecipientSettings.isExpired(). + if ((pro?.status == ProProof.STATUS_VALID || pro?.status == ProProof.STATUS_EXPIRED) && + pro.proof != null) { proProofInfo = Conversation.ProProofInfo( revocationTag = pro.proof!!.revocationTagHex.hexToByteArray(), expirySeconds = pro.proof!!.expirySeconds, ) + // Moves with proProofInfo deliberately: proFeatures lives in the (also synced) + // contacts config, so keeping one without the other just relocates the + // ping-pong. It's inert while the proof is expired, for the reason above. proFeatures = pro.proProfileFeatures } else { proProofInfo = null diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt index 611ce42722..564cb75da5 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -12,7 +12,6 @@ import org.session.libsession.messaging.utilities.MessageAuthentication.buildDel import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature -import org.session.libsession.network.SnodeClock import org.session.protos.SessionProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.IdPrefix @@ -28,7 +27,6 @@ class GroupMessageHandler @Inject constructor( private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, @param:ManagerScope private val scope: CoroutineScope, - private val clock: SnodeClock, ) { fun handleGroupUpdated( message: GroupUpdated, @@ -43,7 +41,7 @@ class GroupMessageHandler @Inject constructor( } // Update profile if needed - ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, pro)?.let { updates -> profileUpdateHandler.handleProfileUpdate( senderId = AccountId(message.sender!!), updates = updates, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 6ceac9c9f4..d86a216b8f 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -6,7 +6,6 @@ import org.session.libsession.messaging.messages.ProfileUpdateHandler import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.signal.IncomingMediaMessage import org.session.libsession.messaging.messages.visible.VisibleMessage -import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol @@ -37,7 +36,6 @@ class MessageRequestResponseHandler @Inject constructor( private val smsDatabase: SmsDatabase, private val threadDatabase: ThreadDatabase, private val blindMappingRepository: BlindMappingRepository, - private val clock: SnodeClock, ) { fun handleVisibleMessage( @@ -90,7 +88,7 @@ class MessageRequestResponseHandler @Inject constructor( // Always process the profile update if any. We don't need // to process profile for other kind of messages as they should be handled elsewhere - ProfileUpdateHandler.Updates.create(proto, clock.currentTimeMillis(), pro)?.let { updates -> + ProfileUpdateHandler.Updates.create(proto, pro)?.let { updates -> profileUpdateHandler.get().handleProfileUpdate( senderId = (sender.address as Address.Standard).accountId, updates = updates, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index fc835fc296..25e8e1597b 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -18,7 +18,6 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.MessageExpirationManagerProtocol @@ -51,7 +50,6 @@ class VisibleMessageHandler @Inject constructor( private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, private val messageExpirationManager: MessageExpirationManagerProtocol, private val typingIndicators: TypingIndicatorsProtocol, - private val clock: SnodeClock, private val jobQueue: Provider, ){ fun handleVisibleMessage( @@ -215,7 +213,6 @@ class VisibleMessageHandler @Inject constructor( if (runProfileUpdate && senderAddress is Address.WithAccountId) { val updates = ProfileUpdateHandler.Updates.create( content = proto, - nowMills = clock.currentTimeMillis(), pro = pro ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 82d96db2fa..62655b2b9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -394,7 +394,7 @@ class ProSettingsViewModel @AssistedInject constructor( viewModelScope.launch { _refundPlanState.update { val isQuickRefund = if(prefs.forceCurrentUserAsPro()) prefs.getDebugIsWithinQuickRefund()// debug mode - else sub.isWithinQuickRefundWindow() + else sub.isWithinQuickRefundWindow(clock.currentTime()) State.Success( RefundPlanState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt index 6a34c214ff..570d2d4a7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt @@ -11,6 +11,12 @@ import network.loki.messenger.libsession_util.pro.BackendRequests * libsession `PaymentProviderMetadata` struct. */ data class PaymentProviderMetadata( + /** + * The opaque provider wire slug (`google_play`, `app_store`, `rangeproof`, …) this metadata was + * built from. Carried so platform decisions key off the slug rather than off one of the + * localized display fields below — see [isFromAnotherPlatform]. + */ + val slug: String, val device: String, val store: String, val platform: String, @@ -30,6 +36,7 @@ data class PaymentProviderMetadata( fun providerMetadata(providerSlug: String, context: Context): PaymentProviderMetadata { val urls = BackendRequests.providerUrls(providerSlug) return PaymentProviderMetadata( + slug = providerSlug, device = providerDisplay(context, providerSlug, "device"), store = providerDisplay(context, providerSlug, "store"), platform = providerDisplay(context, providerSlug, "platform"), diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/PlayStoreAccountId.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PlayStoreAccountId.kt index a5adb59937..8e807bbb6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/PlayStoreAccountId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PlayStoreAccountId.kt @@ -1,8 +1,19 @@ package org.thoughtcrime.securesms.pro import org.session.libsignal.utilities.toHexString -import java.security.MessageDigest +/** + * Derives the `obfuscatedAccountId` we hand to Play Billing from the Pro master key. + * + * The backend binds a redeem by **byte equality against the request-signed master pubkey** + * (`obfuscated_account_id == bytes(master_pkey)`), so the tag is the raw 32-byte Ed25519 public + * key, hex-encoded — *not* a hash of it. The backend accepts 64 hex chars with an optional `0x` + * prefix; we send the bare lowercase form. + * + * Do not reintroduce a digest here: both a hash and the raw key are 64 hex chars, so a mismatch + * decodes cleanly and fails silently on *value* — every purchase would fail to redeem with + * `unknown_payment`. + */ object PlayStoreAccountId { private const val ED25519_SECRET_KEY_LENGTH = 64 private const val ED25519_PUBLIC_KEY_LENGTH = 32 @@ -26,9 +37,6 @@ object PlayStoreAccountId { "Expected a $ED25519_PUBLIC_KEY_LENGTH-byte Ed25519 public key, got ${ed25519PublicKey.size} bytes" } - return MessageDigest - .getInstance("SHA-256") - .digest(ed25519PublicKey) - .toHexString() + return ed25519PublicKey.toHexString() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index 8ec72c9188..422fc8a011 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.pro import android.content.Context +import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_APP_STORE import network.loki.messenger.libsession_util.pro.BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY import network.loki.messenger.libsession_util.pro.GetProStatusResponse import network.loki.messenger.libsession_util.pro.ProPaymentItem @@ -92,18 +93,24 @@ fun ProPaymentItem.toProPlanPeriod(): ProPlanPeriod { return ProPlanPeriod(planCount, unit) } +/** + * Whether the subscription was bought somewhere this device can't manage it — i.e. anywhere other + * than Google Play. Drives the three non-originating screens (Update #7, Cancel #27, Refund + * #22/#23) and price suppression. + * + * Keyed off the provider **slug**, never off a display field: those are localized, so comparing + * them would classify every non-English Google Play subscriber as non-originating. + */ fun PaymentProviderMetadata.isFromAnotherPlatform(): Boolean { - return platform.trim().lowercase() != "google" + return slug != PAYMENT_PROVIDER_GOOGLE_PLAY } /** - * Some UI cases require a special display name for the platform. + * Some UI cases require a special display name for the platform: for our own store the copy reads + * better with the store name ("Google Play") than the platform name ("Google"). */ fun PaymentProviderMetadata.getPlatformDisplayName(): String { - return when (platform.trim().lowercase()) { - "google" -> store - else -> platform - } + return if (slug == PAYMENT_PROVIDER_GOOGLE_PLAY) store else platform } /** @@ -111,6 +118,7 @@ fun PaymentProviderMetadata.getPlatformDisplayName(): String { */ val previewAppleMetaData = PaymentProviderMetadata( + slug = PAYMENT_PROVIDER_APP_STORE, device = "iOS", store = "Apple App Store", platform = "Apple", diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index 9e9e678e6d..c10960edf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -33,8 +33,16 @@ sealed interface ProStatus{ override val refundInProgress: Boolean, ): Active - fun isWithinQuickRefundWindow(): Boolean { - return quickRefundExpiry != null && quickRefundExpiry!!.isAfter(Instant.now()) + /** + * Whether the store's own quick-refund window is still open, which decides between the + * <48h (#19/#22) and >48h (#20/#23) refund screens. + * + * [now] must come from [org.session.libsession.network.SnodeClock], as everywhere else in + * the Pro stack — `quickRefundExpiry` is a backend/store timestamp, so comparing it against + * the device clock lets clock skew flip the branch. + */ + fun isWithinQuickRefundWindow(now: Instant): Boolean { + return quickRefundExpiry?.isAfter(now) == true } fun renewingAtFormatted(): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index c3ea603a78..cc8a37dc4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -253,16 +253,6 @@ class ProStatusManager @Inject constructor( launch { manageOtherPeoplePro() } launch { manageProStatusRefreshScheduling() } launch { manageCurrentProProofRevocation() } - launch { - postProLaunchStatus - .collectLatest { postLaunch -> - if (postLaunch) { - RevocationListPollingWorker.schedule(application) - } else { - RevocationListPollingWorker.cancel(application) - } - } - } } override fun onLoggedOut() { @@ -528,8 +518,28 @@ class ProStatusManager @Inject constructor( configs.userProfile.setProBadge(true) } - // refresh the pro status - proStatusRepository.get().requestRefresh(force = true) + + // The claim is persisted from here on, so nothing below may send us back + // around the retry loop: refreshing the status is only a UI-freshness + // concern, and a scheduled refresh will pick it up regardless. + try { + proStatusRepository.get().requestRefresh(force = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w( + DebugLogGroup.PRO_SUBSCRIPTION.label, + "Pro status refresh after 'add pro payment' failed; the claim is already persisted", + e + ) + } + + // The one and only success exit. Without this the loop runs all 3 attempts + // and then throws PaymentServerException, so a purchase that actually + // worked surfaces to the user as "Payment Error". `already_redeemed` used to + // break the loop by accident; §5.1 removed it (a re-claim now returns ok), + // which left success with no exit at all. Do not remove. + return } is ProApiResponse.Failure -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index 472bcaf14f..f9a4857e8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -60,18 +60,45 @@ class RevocationListPollingWorker @AssistedInject constructor( proDatabase.pruneRevocations(snodeClock.currentTime()) - // Arrange next polling + // Arrange next polling, sanitising the backend's `retry_in` — it goes straight into a + // OneTimeWorkRequest delay on APPEND unique work, so a bad value either hot-loops + // against the backend or disables polling outright. + // + // The agreed cross-client rule (signed off 2026-07-27): + // retry_in <= 0 -> DEFAULT_RETRY_IN_SECONDS (the checklist's 1-day worst case) + // otherwise -> clamp to [MIN, MAX] + // + // Absent/zero deliberately does NOT fall through to the 60s floor: "we were told + // nothing" should mean "try again tomorrow", not "try again in a minute". + // + // Purely defensive — the backend hardcodes RETRY_IN = SECONDS_IN_DAY + // (Session-Pro-Backend server.py:248) and asserts it in tests, so this should never fire + // against a real backend. Revocation is also not latency-critical by design: the backend + // sets effective_at = revoked_at + RETRY_IN (server.py:271), giving every client a full + // poll interval of slack before a revocation takes effect. + // + // TODO: consolidate this rule into libsession once libsession owns networking, so the + // three clients share one implementation. Client-side is the correct home until then. + val retryInSeconds = if (response.retryInSeconds <= 0) { + DEFAULT_RETRY_IN_SECONDS + } else { + response.retryInSeconds.coerceIn(MIN_RETRY_IN_SECONDS, MAX_RETRY_IN_SECONDS) + } + if (retryInSeconds != response.retryInSeconds) { + Log.w(TAG, "Adjusted backend retry_in ${response.retryInSeconds}s to ${retryInSeconds}s") + } + WorkManager.getInstance(context) .beginUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND, OneTimeWorkRequestBuilder() - .setInitialDelay(response.retryInSeconds, TimeUnit.SECONDS) + .setInitialDelay(retryInSeconds, TimeUnit.SECONDS) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(10)) .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED)) .build() ) .enqueue() - Log.d(TAG, "Arranged next polling in ${response.retryInSeconds} seconds") + Log.d(TAG, "Arranged next polling in $retryInSeconds seconds") return Result.success() } catch (e: Exception) { @@ -93,6 +120,21 @@ class RevocationListPollingWorker @AssistedInject constructor( private const val WORK_NAME = "RevocationListPollingWorker" + /** Floor for a positive `retry_in`, so a small value can't turn polling into a hot loop. */ + private const val MIN_RETRY_IN_SECONDS = 60L + + /** + * Ceiling for `retry_in`. The important half: without it, a `retry_in` of (say) ten years is + * honoured and revocation polling is silently disabled for good. + */ + private const val MAX_RETRY_IN_SECONDS = 24L * 60 * 60 + + /** + * Used when `retry_in` is absent or non-positive — the checklist's "worst case hardcode to + * 1 day". Matches the backend's own `RETRY_IN` (`server.py:248`). + */ + private const val DEFAULT_RETRY_IN_SECONDS = MAX_RETRY_IN_SECONDS + suspend fun schedule(context: Context) { WorkManager.getInstance(context) .beginUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt index 0fab234496..f2e98336d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/db/ProDatabase.kt @@ -84,11 +84,26 @@ class ProDatabase @Inject constructor( """).use { stmt -> stmt.bindString(1, STATE_NAME_LAST_TICKET) stmt.bindLong(2, newTicket) + // Must be executed — binding alone writes nothing. Without this + // getLastRevocationTicket() always returns null and every poll re-requests the + // entire revocation list from ticket 0. + stmt.executeInsert() } } + // `cache` is a positive-only "known revoked" set, and isRevoked() trusts a hit without + // re-checking the clock — so only cache items that are ALREADY effective (§4). Caching a + // future-dated revocation here would apply it immediately and bypass the effective_ts check + // in isRevoked()'s query; such an item is left to be picked up from the DB (and cached + // there) once its effective_ts has passed. for (item in data) { - cache.put(item.revocationTagHex, Unit) + if (item.effectiveUnixTs <= now.epochSecond) { + cache.put(item.revocationTagHex, Unit) + } else { + // An update can also push effective_ts forward for a tag we cached on an earlier + // poll; drop it so the DB's effective_ts check governs again. + cache.remove(item.revocationTagHex) + } } if (changes > 0) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f551962aa7..98c8f69ae6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1157,15 +1157,31 @@ 12 Months - {monthly_price} / Month - Google - Google Play Store - Android - Google account - Apple - Apple App Store - iOS - Apple account + next translations sync, or the sync will delete them. Values mirror the old libsession metadata. + + NAMING IS LOAD-BEARING: these are resolved at runtime by name, as + `pro_provider__` (PaymentProviderMetadata.kt), where is + the backend's opaque wire slug (`google_play`, `app_store`, …) and is one of + device/store/platform/account. A missing resource does NOT fail the build — getIdentifier() + returns 0 and the code falls back to `pro_provider_unknown_` and then to the raw slug, + so a rename that misses these silently ships wire slugs as user-facing copy. Renaming to the + camelCase house style would break the lookup; keep the snake_case slug form. + + `pro_provider_unknown_*` are the forward-compatible fallback for a provider the client has no + translations for yet; `{provider}` (where present) is substituted with the raw slug. Adding a + new provider should need only these four strings, no code change. --> + Google + Google Play Store + Android + Google account + Apple + Apple App Store + iOS + Apple account + {provider} + {provider} + {provider} + {provider} account re-activating Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings. We’re sorry to see you go. Here\'s what you need to know before requesting a refund. diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 126781385d..910f4085d2 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -76,7 +76,15 @@ class PlayStoreSubscriptionManager @Inject constructor( if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ + // No key means we're logged out (e.g. a pending purchase resolving after a + // logout) — there's nobody to attribute the purchase to, so drop it rather + // than throwing from this Play SDK callback thread. val expected = obfuscatedProId() + if (expected == null) { + Log.w(TAG, "Ignoring purchase: no logged-in Pro master key to attribute it to") + return@setListener + } + val purchaseAccountId = it.accountIdentifiers?.obfuscatedAccountId if (purchaseAccountId != expected) { @@ -138,8 +146,12 @@ class PlayStoreSubscriptionManager @Inject constructor( // Check for existing subscription val existingPurchase = getExistingSubscription() + val obfuscatedAccountId = checkNotNull(obfuscatedProId()) { + "User must be logged in to purchase Pro" + } + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() - .setObfuscatedAccountId(obfuscatedProId()) + .setObfuscatedAccountId(obfuscatedAccountId) .setProductDetailsParamsList( listOf( BillingFlowParams.ProductDetailsParams.newBuilder() @@ -187,10 +199,16 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - private fun obfuscatedProId(): String { - val proMasterPrivateKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { - "User must be logged in to access Pro" - } + /** + * The account tag we bind purchases to, or null if there's no logged-in Pro master key. + * + * Nullable rather than throwing: this is called from the BillingClient purchases-updated + * listener, so a pending purchase resolving after a logout would otherwise throw from a Play + * SDK callback thread. + */ + private fun obfuscatedProId(): String? { + val proMasterPrivateKey = + loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey ?: return null return PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/pro/PlayStoreAccountIdTest.kt b/app/src/test/java/org/thoughtcrime/securesms/pro/PlayStoreAccountIdTest.kt index 2ca18e722d..833b80e6ec 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/pro/PlayStoreAccountIdTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/pro/PlayStoreAccountIdTest.kt @@ -7,30 +7,77 @@ import org.session.libsignal.utilities.toHexString import org.junit.Test import java.security.MessageDigest +/** + * The backend binds a Google Play redeem with `obfuscated_account_id == bytes(master_pkey)` + * (`backend.py`, `redeem_payment`), decoding Play's `obfuscatedExternalAccountId` as 64 hex chars + * → 32 bytes. So the account id must be the **raw** Pro master Ed25519 public key in hex. + * + * Two earlier schemes both produced 64 hex chars and so decoded cleanly while failing on value + * (every purchase → `unknown_payment`). Both are pinned as negative cases below. + */ class PlayStoreAccountIdTest { + private val proMasterPrivateKey = Hex.fromStringCondensed( + "728db9098cc9095de5a4838c884a2920e04ec46a568693047d54f6a0fe3d5718" + + "83d36e9ae51851014f686bd6089dc93061292d1f516e65b44f1d3f6bb7504a81" + ) + private val ed25519PublicKeyHex = + "83d36e9ae51851014f686bd6089dc93061292d1f516e65b44f1d3f6bb7504a81" + private val ed25519PublicKey = Hex.fromStringCondensed(ed25519PublicKeyHex) + @Test - fun hashes_public_component_of_pro_master_key() { - val proMasterPrivateKey = Hex.fromStringCondensed( - "728db9098cc9095de5a4838c884a2920e04ec46a568693047d54f6a0fe3d5718" + - "83d36e9ae51851014f686bd6089dc93061292d1f516e65b44f1d3f6bb7504a81" + fun uses_raw_public_component_of_pro_master_key() { + assertEquals( + ed25519PublicKeyHex, + PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) ) - val ed25519PublicKey = Hex.fromStringCondensed( - "83d36e9ae51851014f686bd6089dc93061292d1f516e65b44f1d3f6bb7504a81" + assertEquals( + ed25519PublicKeyHex, + PlayStoreAccountId.fromEd25519PublicKey(ed25519PublicKey) ) + } - val legacyAndroidAccountId = MessageDigest + @Test + fun is_the_64_char_bare_lowercase_hex_the_backend_decodes() { + val accountId = PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) + + // Google caps obfuscatedAccountId at 64 chars, and the backend requires exactly 64 + // (after stripping an optional "0x") before `bytes.fromhex`. + assertEquals(64, accountId.length) + assertEquals(accountId.lowercase(), accountId) + assertEquals(false, accountId.startsWith("0x")) + assertEquals( + ed25519PublicKey.toList(), + Hex.fromStringCondensed(accountId).toList() + ) + } + + @Test + fun does_not_hash_the_public_key() { + // Superseded scheme (backend commit 60ea9f6 dropped the sha256): also 64 hex chars, so a + // regression here would decode fine and silently fail to redeem. + val supersededPubKeyHash = MessageDigest .getInstance("SHA-256") - .digest(proMasterPrivateKey.toHexString().toByteArray(Charsets.UTF_8)) + .digest(ed25519PublicKey) .toHexString() assertEquals( "d62b1dd03833e4fee9cd2cee95014520da37fd15c86bb422d221a324193a326b", - PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) + supersededPubKeyHash ) - assertEquals( - "d62b1dd03833e4fee9cd2cee95014520da37fd15c86bb422d221a324193a326b", - PlayStoreAccountId.fromEd25519PublicKey(ed25519PublicKey) + assertNotEquals( + supersededPubKeyHash, + PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) ) + } + + @Test + fun does_not_hash_the_private_key_hex() { + // The original (pre-3c1cb22) scheme: sha256 of the hex *string* of the 64-byte secret key. + val legacyAndroidAccountId = MessageDigest + .getInstance("SHA-256") + .digest(proMasterPrivateKey.toHexString().toByteArray(Charsets.UTF_8)) + .toHexString() + assertEquals( "e062990b72bd6870ab27c0d3d6f4db7f729b769a76c7fff89e39ba39f8d5b957", legacyAndroidAccountId @@ -40,4 +87,14 @@ class PlayStoreAccountIdTest { PlayStoreAccountId.fromProMasterPrivateKey(proMasterPrivateKey) ) } + + @Test(expected = IllegalArgumentException::class) + fun rejects_a_secret_key_of_the_wrong_length() { + PlayStoreAccountId.fromProMasterPrivateKey(ed25519PublicKey) + } + + @Test(expected = IllegalArgumentException::class) + fun rejects_a_public_key_of_the_wrong_length() { + PlayStoreAccountId.fromEd25519PublicKey(proMasterPrivateKey) + } } From a61a287f132bb700eff21cf147a00dcd088f5608 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Mon, 27 Jul 2026 21:36:57 -0300 Subject: [PATCH 20/20] Pro: drop client-side revocation retry_in clamp (now in libsession) libsession's parse_revocations clamps retry_in ([60s,48h]) and retain_for ([24h,365d]) as of 7d422cfd, so RevocationListPollingWorker no longer needs its own coerceIn/default logic (and the MIN/MAX/DEFAULT constants). Resolves the in-code TODO to consolidate the rule into libsession. Ceiling now follows libsession's 48h rather than the old 24h. Requires the glue AAR to be republished on a libsession pin including 7d422cfd. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pro/RevocationListPollingWorker.kt | 48 +++---------------- 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index f9a4857e8c..539273ef5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -60,33 +60,12 @@ class RevocationListPollingWorker @AssistedInject constructor( proDatabase.pruneRevocations(snodeClock.currentTime()) - // Arrange next polling, sanitising the backend's `retry_in` — it goes straight into a - // OneTimeWorkRequest delay on APPEND unique work, so a bad value either hot-loops - // against the backend or disables polling outright. - // - // The agreed cross-client rule (signed off 2026-07-27): - // retry_in <= 0 -> DEFAULT_RETRY_IN_SECONDS (the checklist's 1-day worst case) - // otherwise -> clamp to [MIN, MAX] - // - // Absent/zero deliberately does NOT fall through to the 60s floor: "we were told - // nothing" should mean "try again tomorrow", not "try again in a minute". - // - // Purely defensive — the backend hardcodes RETRY_IN = SECONDS_IN_DAY - // (Session-Pro-Backend server.py:248) and asserts it in tests, so this should never fire - // against a real backend. Revocation is also not latency-critical by design: the backend - // sets effective_at = revoked_at + RETRY_IN (server.py:271), giving every client a full - // poll interval of slack before a revocation takes effect. - // - // TODO: consolidate this rule into libsession once libsession owns networking, so the - // three clients share one implementation. Client-side is the correct home until then. - val retryInSeconds = if (response.retryInSeconds <= 0) { - DEFAULT_RETRY_IN_SECONDS - } else { - response.retryInSeconds.coerceIn(MIN_RETRY_IN_SECONDS, MAX_RETRY_IN_SECONDS) - } - if (retryInSeconds != response.retryInSeconds) { - Log.w(TAG, "Adjusted backend retry_in ${response.retryInSeconds}s to ${retryInSeconds}s") - } + // Arrange next polling. `retry_in` goes straight into a OneTimeWorkRequest delay on + // APPEND unique work, so a nonsensical value would either hot-loop against the backend or + // disable polling outright — but libsession now clamps both `retry_in` and `retain_for` + // to sane bounds in `parse_revocations`, so we use the parsed value as-is here rather than + // re-clamping client-side. + val retryInSeconds = response.retryInSeconds WorkManager.getInstance(context) .beginUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND, @@ -120,21 +99,6 @@ class RevocationListPollingWorker @AssistedInject constructor( private const val WORK_NAME = "RevocationListPollingWorker" - /** Floor for a positive `retry_in`, so a small value can't turn polling into a hot loop. */ - private const val MIN_RETRY_IN_SECONDS = 60L - - /** - * Ceiling for `retry_in`. The important half: without it, a `retry_in` of (say) ten years is - * honoured and revocation polling is silently disabled for good. - */ - private const val MAX_RETRY_IN_SECONDS = 24L * 60 * 60 - - /** - * Used when `retry_in` is absent or non-positive — the checklist's "worst case hardcode to - * 1 day". Matches the backend's own `RETRY_IN` (`server.py:248`). - */ - private const val DEFAULT_RETRY_IN_SECONDS = MAX_RETRY_IN_SECONDS - suspend fun schedule(context: Context) { WorkManager.getInstance(context) .beginUniqueWork(WORK_NAME, ExistingWorkPolicy.KEEP,