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 75dc0fab54..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!!.expiryMs > nowMills) { + // 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( - genIndexHash = pro.proof!!.genIndexHashHex.hexToByteArray(), - expiryMs = pro.proof!!.expiryMs, + 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/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/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/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index a3e6f2cf27..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,12 +50,13 @@ 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" 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/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..dc10149c88 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(), ) ) } @@ -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/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/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/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/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 0d10378d51..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 @@ -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 @@ -52,11 +52,12 @@ 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 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 @@ -80,7 +81,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, @@ -393,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( @@ -488,7 +489,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 +578,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 { @@ -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 { @@ -753,7 +750,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 +789,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? { @@ -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,64 @@ class ProSettingsViewModel @AssistedInject constructor( val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) - 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) - } - ) - } + // 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 + // 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/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/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/FetchProDetailsWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt similarity index 70% 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 6efe79e888..626d2ebced 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 org.thoughtcrime.securesms.pro.api.GetProDetailsApi -import org.thoughtcrime.securesms.pro.api.ProDetails +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,44 +59,53 @@ 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.status}, " + + "Fetched pro status, status = ${details.userStatus}, " + "autoRenew = ${details.autoRenewing}, expiry = ${details.expiry}" ) configFactory.withMutableUserConfigs { configs -> - if (details.expiry != null) { - configs.userProfile.setProAccessExpiryMs(details.expiry.toEpochMilli()) + // 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() } - // 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) { + // 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() } } - proDatabase.updateProDetails(proDetails = details, updatedAt = snodeClock.currentTime()) + proDatabase.updateProStatus(proStatus = details, updatedAt = snodeClock.currentTime()) scheduleProofGenerationIfNeeded(details) @@ -105,31 +114,31 @@ 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: ProDetails) { + private suspend fun scheduleProofGenerationIfNeeded(details: GetProStatusResponse) { 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 { 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 ) { @@ -146,7 +155,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 @@ -167,7 +176,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/PaymentProviderMetadata.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt new file mode 100644 index 0000000000..570d2d4a7f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/PaymentProviderMetadata.kt @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.pro + +import android.content.Context +import com.squareup.phrase.Phrase +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 — 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( + /** + * 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, + 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 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) + return PaymentProviderMetadata( + slug = providerSlug, + 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(), + updateSubscriptionUrl = urls?.updateSubscriptionUrl.orEmpty(), + 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/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 ebb7f58687..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,101 +1,124 @@ package org.thoughtcrime.securesms.pro -import network.loki.messenger.libsession_util.pro.BackendRequests +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.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.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 -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-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". + */ +object ProUserStatus { + const val NEVER = "never" + const val ACTIVE = "active" + const val EXPIRED = "expired" +} - if (isAutoRenewing) { +/** + * 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 GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { + return when (userStatus) { + ProUserStatus.ACTIVE -> { + 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() + val renewingAt = Instant.ofEpochMilli(renewingAtMs) + val providerData = providerMetadata(paymentItem.paymentProvider, context) + val duration = paymentItem.toProPlanPeriod() + val refundInProgress = refundRequested != null + + // 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 + // 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 = 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( + latestPayment?.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 - } +/** + * The billing period as a (count, unit) [ProPlanPeriod]. libsession parses the wire `plan` grammar + * (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 + * unrecognized unit name shouldn't occur (closed grammar); we fall back to a one-month period. + */ +fun ProPaymentItem.toProPlanPeriod(): ProPlanPeriod { + val unit = ProPlanUnit.fromWireName(planUnit) ?: ProPlanUnit.MONTH + 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 } - /** * Preview Data - Reusable data for composable previews */ val previewAppleMetaData = PaymentProviderMetadata( + slug = PAYMENT_PROVIDER_APP_STORE, device = "iOS", store = "Apple App Store", platform = "Apple", @@ -109,7 +132,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, @@ -120,5 +143,3 @@ val previewExpiredApple = ProStatus.Expired( expiredAt = Instant.now() - Duration.ofDays(14), providerData = previewAppleMetaData ) - - 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/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 +} 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..20fc4c3dd1 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 @@ -38,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( @@ -47,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) { @@ -56,18 +55,18 @@ 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.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() + // §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 { it.userProfile.setProConfig(ProConfig( @@ -84,7 +85,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/ProStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt index 7f11072558..c10960edf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatus.kt @@ -1,8 +1,7 @@ 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.pro.subscription.ProPlanPeriod import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import java.time.Instant @@ -12,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, @@ -28,14 +27,22 @@ 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, ): 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 cab441e9ea..cc8a37dc4f 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 @@ -92,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 { @@ -107,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() }, @@ -117,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) } } @@ -140,7 +141,7 @@ class ProStatusManager @Inject constructor( val nowMs = snodeClock.currentTimeMillis() ProDataState( - type = proDetailsState.lastUpdated?.first?.toProStatus(nowMs) ?: ProStatus.NeverSubscribed, + type = proStatusState.lastUpdated?.first?.toProStatus(nowMs, application) ?: ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -153,8 +154,8 @@ class ProStatusManager @Inject constructor( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.THREE_MONTHS, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY)!!, + duration = ProSubscriptionDuration.THREE_MONTHS.period, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false @@ -162,8 +163,8 @@ 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)!!, + duration = ProSubscriptionDuration.THREE_MONTHS.period, + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = true, inGracePeriod = false @@ -171,24 +172,24 @@ 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)!!, + duration = ProSubscriptionDuration.TWELVE_MONTHS.period, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false ) 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)!!, + duration = ProSubscriptionDuration.TWELVE_MONTHS.period, + providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> ProStatus.Active.AutoRenewing( renewingAt = Instant.now() + Duration.ofDays(14), - duration = ProSubscriptionDuration.ONE_MONTH, - providerData = BackendRequests.getPaymentProviderMetadata(PAYMENT_PROVIDER_APP_STORE)!!, + duration = ProSubscriptionDuration.ONE_MONTH.period, + providerData = providerMetadata(PAYMENT_PROVIDER_APP_STORE, application), quickRefundExpiry = Instant.now() + Duration.ofDays(7), refundInProgress = false, inGracePeriod = false @@ -196,23 +197,23 @@ 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)!!, + duration = ProSubscriptionDuration.ONE_MONTH.period, + 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) ) }, @@ -250,18 +251,8 @@ class ProStatusManager @Inject constructor( } launch { manageOtherPeoplePro() } - launch { manageProDetailsRefreshScheduling() } + launch { manageProStatusRefreshScheduling() } launch { manageCurrentProProofRevocation() } - launch { - postProLaunchStatus - .collectLatest { postLaunch -> - if (postLaunch) { - RevocationListPollingWorker.schedule(application) - } else { - RevocationListPollingWorker.cancel(application) - } - } - } } override fun onLoggedOut() { @@ -284,7 +275,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 @@ -311,7 +302,7 @@ class ProStatusManager @Inject constructor( } @OptIn(FlowPreview::class) - private suspend fun manageProDetailsRefreshScheduling() { + private suspend fun manageProStatusRefreshScheduling() { postProLaunchStatus .collectLatest { postLaunch -> if (postLaunch) { @@ -320,13 +311,13 @@ class ProStatusManager @Inject constructor( .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) .map { configFactory.get().withUserConfigs { configs -> - configs.userProfile.getProAccessExpiryMs() + configs.userProfile.getProAccessExpiry() } } .distinctUntilChanged() .map { "ProAccessExpiry in config changes" }, - proDetailsRepository.get().loadState + proStatusRepository.get().loadState .mapNotNull { it.lastUpdated?.first?.expiry } .distinctUntilChanged() .transformLatest { expiry -> @@ -341,7 +332,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 = @@ -356,13 +347,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) } } } @@ -373,19 +364,19 @@ class ProStatusManager @Inject constructor( combine( configFactory.get() .watchUserProConfig() - .mapNotNull { it?.proProof?.genIndexHashHex }, + .mapNotNull { it?.proProof?.revocationTagHex }, proDatabase.revocationChangeNotification .onStart { emit(Unit) }, - { proofGenIndexHash, _ -> - proofGenIndexHash.takeIf { proDatabase.isRevoked(it, snodeClock.currentTime()) } + { proofRevocationTag, _ -> + proofRevocationTag.takeIf { proDatabase.isRevoked(it, snodeClock.currentTime()) } } ) .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" @@ -503,7 +494,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,35 +507,54 @@ class ProStatusManager @Inject constructor( configFactory.get().withMutableUserConfigs { configs -> configs.userProfile.setProConfig( ProConfig( - proProof = paymentResponse.data, + // §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" + }, rotatingPrivateKey = rotatingKeyPair.secretKey.data ) ) configs.userProfile.setProBadge(true) } - // refresh the pro details - proDetailsRepository.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 -> { - // 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() - } + // §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) { + throw Exception() + } else { + // Permanent fault: surface the specific reason (error_code slug -> + // localized string, falling back to the backend diagnostic). + throw SubscriptionManager.NonRetryableProPaymentException( + paymentResponse.error.userFacingMessage(application) + ) } } } @@ -551,6 +564,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 @@ -573,7 +590,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/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 5f66b6cda1..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 org.thoughtcrime.securesms.pro.api.ProDetails +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/RevocationListPollingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt index ae288be303..539273ef5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/RevocationListPollingWorker.kt @@ -53,23 +53,31 @@ 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()) - // Arrange next polling + // 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, 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) { 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..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 @@ -3,10 +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 org.session.libsignal.utilities.Log +import network.loki.messenger.libsession_util.pro.ProProofResponse +import network.loki.messenger.libsession_util.pro.ProRequest class AddProPaymentApi @AssistedInject constructor( @Assisted("token") private val googlePaymentToken: String, @@ -14,29 +13,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, - 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", ) - } - - override fun convertErrorStatus(status: Int): AddPaymentErrorStatus { - Log.w("", "AddProPayment: convertErrorStatus: $status") - return AddPaymentErrorStatus.entries.firstOrNull { it.apiValue == status } - ?: AddPaymentErrorStatus.GenericError - } - override val responseDeserializer: DeserializationStrategy - get() = ProProof.serializer() + override fun parseResponse(json: String): ProProofResponse = + BackendRequests.parseAddPaymentResponse(json) @AssistedFactory interface Factory { @@ -48,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 f243de7306..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 @@ -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,25 +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 convertErrorStatus(status: Int): GetProProofStatus = status + override fun parseResponse(json: String): ProProofResponse = + BackendRequests.parseProProofResponse(json) @AssistedFactory interface Factory { @@ -40,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 deleted file mode 100644 index 0296872dbf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProDetailsApi.kt +++ /dev/null @@ -1,139 +0,0 @@ -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.PaymentProvider -import org.session.libsession.network.SnodeClock -import org.session.libsession.utilities.serializable.InstantAsMillisSerializer -import java.time.Instant - -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, - nowMs = snodeClock.currentTimeMillis(), - count = 10, - ) - } - - override val responseDeserializer: DeserializationStrategy - get() = ProDetails.serializer() - - override fun convertErrorStatus(status: Int): Int = status - - @AssistedFactory - interface Factory { - fun create(masterPrivateKey: ByteArray): GetProDetailsApi - } -} - -typealias ServerProDetailsStatus = Int -typealias ServerPlanDuration = Int - -@Serializable -class ProDetails( - val status: ServerProDetailsStatus, - - @SerialName("auto_renewing") - val autoRenewing: Boolean? = null, - - @SerialName("expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) - val expiry: Instant? = null, - - @SerialName("grace_period_duration_ms") - val graceDurationMs: 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_unix_ts_ms") - val refundRequestedAtMs: 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 planDuration: ServerPlanDuration, - - val status: Int, // Payment status [Redeemed, Revoked, Expired] - we do not use this status in the clients - - @SerialName("payment_provider") - val paymentProvider: PaymentProvider, - - @SerialName("expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) - val expiry: Instant? = null, - - @SerialName("grace_period_duration_ms") - val graceDurationMs: Long? = null, - - @SerialName("platform_refund_expiry_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) - val platformExpiry: Instant? = null, - - @SerialName("redeemed_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) - val timeRedeemed: Instant? = null, - - @SerialName("unredeemed_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::class) - val timeUnredeemed: Instant? = null, - - @SerialName("revoked_unix_ts_ms") - @Serializable(with = InstantAsMillisSerializer::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, - ) - - 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..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 @@ -3,60 +3,22 @@ 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.InstantAsMillisSerializer -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 buildJsonBody(): String { - return json.encodeToString( - mapOf( - "ticket" to (ticket ?: 0L), - "version" to 0 - ) - ) - } + override fun parseResponse(json: String): GetProRevocationsResponse = + BackendRequests.parseRevocationsResponse(json) @AssistedFactory interface Factory { fun create(ticket: Long?): GetProRevocationApi } } - -@Serializable -class ProRevocations( - val ticket: Long, - val items: List, - @SerialName("retry_in_s") - val retryInSeconds: 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") - val effectiveFrom: Instant, - - @SerialName("gen_index_hash") - val genIndexHash: String, - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt new file mode 100644 index 0000000000..03c15f3de1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/api/GetProStatusApi.kt @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.pro.api + +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.GetProStatusResponse +import network.loki.messenger.libsession_util.pro.ProRequest +import org.session.libsession.network.SnodeClock + +class GetProStatusApi @AssistedInject constructor( + private val snodeClock: SnodeClock, + @Assisted private val masterPrivateKey: ByteArray, + deps: ProApiDependencies, +) : ProApi(deps) { + override fun buildProRequest(): ProRequest = + BackendRequests.buildGetProStatusRequest( + masterPrivateKey = masterPrivateKey, + nowSeconds = snodeClock.currentTimeMillis() / 1000, + ) + + override fun parseResponse(json: String): GetProStatusResponse = + BackendRequests.parseProStatusResponse(json) + + @AssistedFactory + interface Factory { + fun create(masterPrivateKey: ByteArray): GetProStatusApi + } +} 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..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 @@ -1,10 +1,9 @@ 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 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 @@ -19,57 +18,53 @@ 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) { - /** - * The endpoint (path) for this API request, e.g. "v1/pro/payments" - */ - abstract val endpoint: String +abstract class ProApi(private val deps: ProApiDependencies) + : ServerApi>(deps.errorManager) { - abstract val responseDeserializer: DeserializationStrategy + /** Builds the request (endpoint + signed body) via libsession. */ + abstract fun buildProRequest(): ProRequest - abstract fun convertErrorStatus(status: Int): ErrorStatus - - abstract fun buildJsonBody(): String + /** Parses the raw response body into a typed struct via libsession. */ + abstract fun parseResponse(json: String): Res override fun buildRequest( baseUrl: String, x25519PubKeyHex: String ): HttpRequest { + val request = buildProRequest() return HttpRequest( method = "POST", - url = "$baseUrl/$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 "application/json" + "Content-Type" to request.contentType ), - 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) } + ): 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) - 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() + ProApiError( + status = parsed.header.status, + errorCode = parsed.header.errorCode, + error = parsed.header.error, + ) ) } } @@ -78,40 +73,65 @@ 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, - ) +/** + * 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. + */ +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 +} 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..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 @@ -12,8 +12,8 @@ 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 org.thoughtcrime.securesms.pro.api.ProRevocations +import network.loki.messenger.libsession_util.pro.GetProStatusResponse +import network.loki.messenger.libsession_util.pro.ProRevocationItem import org.thoughtcrime.securesms.util.asSequence import java.time.Instant import javax.inject.Inject @@ -48,25 +48,29 @@ class ProDatabase @Inject constructor( fun updateRevocations( newTicket: Long, - data: List + 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.revocationTagHex) + stmt.bindLong(2, item.effectiveUnixTs) + stmt.bindLong(3, retainUntil) changes += stmt.executeUpdateDelete() stmt.clearBindings() } @@ -80,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.genIndexHash, 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) { @@ -96,59 +115,64 @@ 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 } } - 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: ProDetails? = 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)) { - STATE_PRO_DETAILS -> details = json.decodeFromString(cursor.getString(1)) - STATE_PRO_DETAILS_UPDATED_AT -> updatedAt = Instant.ofEpochMilli(cursor.getString(1).toLong()) + // Tolerate a stale/incompatible cached blob (e.g. an older shape): drop it and let + // the next fetch repopulate, rather than throwing. + 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") } } @@ -161,22 +185,22 @@ class ProDatabase @Inject constructor( } } - fun updateProDetails(proDetails: ProDetails, 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) } } @@ -187,13 +211,16 @@ 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 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( @@ -219,5 +246,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 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..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,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). + * 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/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, 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb8236f4..98c8f69ae6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1155,6 +1155,33 @@ 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 + {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) + } }