From a0e9b0246393f5e651d5e3d1417bc09a2c3bea75 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 19:12:23 -0300 Subject: [PATCH 1/8] =?UTF-8?q?WIP:=20Pro=20redemption=20reflow=20?= =?UTF-8?q?=E2=80=94=20Android=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the app to implicit redemption + libsession-owned rotation/renewal (pairs with the glue on branch pro-redemption-reflow / libsession 58bafc4a): - Delete add_pro_payment: remove AddProPaymentApi, ProStatusManager.addProPayment, the PurchaseEvent.Failed.ServerError retry UX + PaymentServer/NonRetryable exceptions. New ProStatusManager.onPurchaseInFlight() = setProPrepaid(now) + schedule proof worker. - ProProofGenerationWorker: rotating key via ED25519.proRotatingSeed(masterKey, now) (deterministic weekly seed) instead of random; runs for ACTIVE *or* pro_prepaid (redemption); 'not Pro yet' is retryable while prepaid (WorkManager exp-backoff = capped client poll; pro_prepaid 1-week gate terminates). - FetchProStatusWorker: renewal via UserProfile.getProRenewalTarget(now) (drops the autoRenewing/expiry bug); also drives the redemption poll when a (possibly synced) pro_prepaid marker is seen while not active. - PlayStore listener: only mark in-flight on PURCHASED (skip PENDING). - Refund 'in progress' now read from synced config (getRefundRequested) — the wire field is gone; threaded into ProDataMapper.toProStatus. Verified: glue builds green standalone; app-side inspection-clean (no dangling refs). Local app compile blocked by the pre-existing local-glue AGP/kotlin.android plugin mismatch. Remaining: purchase-pending UI (getProPrepaid) display. WIP; not pushed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../prosettings/ProSettingsViewModel.kt | 47 ------ .../securesms/pro/FetchProStatusWorker.kt | 52 ++++--- .../securesms/pro/ProDataMapper.kt | 3 +- .../securesms/pro/ProProofGenerationWorker.kt | 33 +++-- .../securesms/pro/ProStatusManager.kt | 139 +++--------------- .../securesms/pro/api/AddProPaymentApi.kt | 38 ----- .../pro/subscription/SubscriptionManager.kt | 32 +--- .../PlayStoreSubscriptionManager.kt | 8 + 8 files changed, 86 insertions(+), 266 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt 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 62655b2b9d..56c2ec8af6 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 @@ -144,53 +144,6 @@ class ProSettingsViewModel @AssistedInject constructor( ).show() } - is SubscriptionManager.PurchaseEvent.Failed.ServerError -> { - // this is a special case of failure. We should display a custom dialog and allow the user to retry - _dialogState.update { - val action = context.getString( - when(_proSettingsUIState.value.proDataState.type) { - is ProStatus.Active -> R.string.proUpdatingAction - is ProStatus.Expired -> R.string.proRenewingAction - else -> R.string.proUpgradingAction - } - ) - - it.copy( - showSimpleDialog = SimpleDialogData( - title = context.getString(R.string.paymentError), - message = Phrase.from(context, R.string.paymentProError) - .put(ACTION_TYPE_KEY, action) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - positiveText = context.getString(R.string.retry), - negativeText = context.getString(R.string.helpSupport), - positiveStyleDanger = false, - showXIcon = true, - onPositive = { - // show the loader again - val data = choosePlanState.value - if(data is State.Success) { - _choosePlanState.update { - State.Success( - data.value.copy(purchaseInProgress = true) - ) - } - } - - // retry the post purchase code - subscriptionCoordinator.getCurrentManager().onPurchaseSuccessful( - orderId = purchaseEvent.orderId, - paymentId = purchaseEvent.paymentId - ) - }, - onNegative = { - onCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) - } - ) - ) - } - } - is SubscriptionManager.PurchaseEvent.Cancelled -> { // nothing to do in this case } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt index 626d2ebced..012a2abb59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -124,33 +124,39 @@ class FetchProStatusWorker @AssistedInject constructor( private suspend fun scheduleProofGenerationIfNeeded(details: GetProStatusResponse) { - val now = snodeClock.currentTimeMillis() - 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.expirySeconds * 1000L <= now) { - Log.d( - TAG, - "Pro is active but no valid proof found, scheduling proof generation now" - ) + // Not (yet) Pro — but if a purchase is in flight (possibly synced from another device that + // bought and set pro_prepaid), keep driving the redemption poll so any device can pull the + // entitlement through. Otherwise there's nothing to generate. + val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null + if (purchasePending) { + Log.d(TAG, "Not active but a purchase is in flight; scheduling proof redemption") ProProofGenerationWorker.schedule(context) - } else if (currentProof.expirySeconds * 1000L - now <= Duration.ofMinutes(60).toMillis() && - details.expiry!!.toEpochMilli() - now > Duration.ofMinutes(60).toMillis() && - details.autoRenewing == true - ) { - val delay = Duration.ofMinutes((Math.random() * 50 + 10).toLong()) - Log.d(TAG, "Pro proof is expiring soon, scheduling proof generation in $delay") - ProProofGenerationWorker.schedule(context, delay) } else { - Log.d( - TAG, - "Pro proof is still valid for a long period, no need to schedule proof generation" - ) + Log.d(TAG, "Pro is not active, cancelling any existing proof generation work") + ProProofGenerationWorker.cancel(context) } + return + } + + // libsession owns the renewal schedule now — no more client-side autoRenewing/expiry logic (which + // was inconsistent and skipped non-auto-renewing but still-valid entitlements). getProRenewalTarget + // returns null (valid proof, no renewal needed), a target <= now (renew now), or a future target + // (~1h before proof expiry, nudged off the rotation-period boundary so all devices converge). + val nowSeconds = snodeClock.currentTime().epochSecond + val target = configFactory.withUserConfigs { it.userProfile.getProRenewalTarget(nowSeconds) } + if (target == null) { + Log.d(TAG, "Pro proof is still valid; no renewal needed") + return + } + + val delay = Duration.ofSeconds((target - nowSeconds).coerceAtLeast(0L)) + if (delay.isZero) { + Log.d(TAG, "Pro proof needs (re)generation now, scheduling immediately") + ProProofGenerationWorker.schedule(context) + } else { + Log.d(TAG, "Pro proof renewal due in $delay, scheduling") + ProProofGenerationWorker.schedule(context, delay) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt index 422fc8a011..929ad6f5a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProDataMapper.kt @@ -26,7 +26,7 @@ object ProUserStatus { * 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 { +fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context, refundInProgress: Boolean): ProStatus { return when (userStatus) { ProUserStatus.ACTIVE -> { val paymentItem = latestPayment ?: return ProStatus.NeverSubscribed @@ -36,7 +36,6 @@ fun GetProStatusResponse.toProStatus(nowMs: Long, context: Context): ProStatus { 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 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 20fc4c3dd1..7cee4e4373 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -16,8 +16,10 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.pro.ProConfig +import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.withMutableUserConfigs +import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.api.error.UnhandledStatusCodeException @@ -49,22 +51,29 @@ class ProProofGenerationWorker @AssistedInject constructor( private val proStatusRepository: ProStatusRepository, private val loginStateRepository: LoginStateRepository, private val configFactory: ConfigFactoryProtocol, + private val snodeClock: SnodeClock, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { "User must be logged to generate proof" } - val details = checkNotNull(proStatusRepository.loadState.value.lastUpdated) { - "Pro status must be available to generate proof" - } - - check(details.first.userStatus == ProUserStatus.ACTIVE) { - "Pro status must be active to generate proof" + // Run when we're already Pro (proof renewal) OR when a purchase is in flight (redemption): in the + // latter case get_pro_status may not be ACTIVE yet, and calling generate_pro_proof is what pulls + // the entitlement through once the backend has ingested the payment. If neither holds, nothing to do. + val isActive = proStatusRepository.loadState.value.lastUpdated?.first?.userStatus == ProUserStatus.ACTIVE + val purchasePending = configFactory.withUserConfigs { it.userProfile.getProPrepaid() } != null + if (!isActive && !purchasePending) { + Log.d(WORK_NAME, "Not Pro and no purchase in flight; nothing to generate") + return Result.success() } return try { - val rotatingPrivateKey = ED25519.generate(null).secretKey.data + // Rotating key is the deterministic weekly seed derived from the Pro master key (libsession + // owns the rotation schedule), so every device converges on the same key per rotation period + // instead of each generating a random one. Expand the 32-byte seed to a full keypair. + val rotatingSeed = ED25519.proRotatingSeed(proMasterKey, snodeClock.currentTime().epochSecond) + val rotatingPrivateKey = ED25519.generate(rotatingSeed).secretKey.data val response = apiExecutor.execute( ServerApiRequest( @@ -91,9 +100,13 @@ class ProProofGenerationWorker @AssistedInject constructor( if (e is CancellationException) throw e Log.e(WORK_NAME, "Error generating Pro proof", e) - if (e is NonRetryableException || - // HTTP 403 indicates that the user is not - e.findCause()?.code == 403) { + // 403 / NonRetryable normally means "not entitled" -> stop. But while a purchase is in flight + // that same "not Pro yet" response just means the backend hasn't ingested the payment yet, so + // keep polling (WorkManager's exponential backoff is our capped poll; the pro_prepaid 1-week + // gate checked at the top of doWork() eventually terminates it). + val notEntitled = e is NonRetryableException || + e.findCause()?.code == 403 + if (notEntitled && !purchasePending) { Result.failure() } else { Result.retry() 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 cc8a37dc4f..c5bee9b15b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -65,7 +65,6 @@ import org.thoughtcrime.securesms.debugmenu.DebugLogGroup import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.ManagerScope 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 import org.thoughtcrime.securesms.pro.db.ProDatabase @@ -88,7 +87,6 @@ class ProStatusManager @Inject constructor( private val prefs: TextSecurePreferences, @param:ManagerScope private val scope: CoroutineScope, private val serverApiExecutor: ServerApiExecutor, - private val addProPaymentApiFactory: AddProPaymentApi.Factory, private val backendConfig: Provider, private val loginState: LoginStateRepository, private val proDatabase: ProDatabase, @@ -140,8 +138,12 @@ class ProStatusManager @Inject constructor( Log.d(DebugLogGroup.PRO_DATA.label, "ProStatusManager: Getting REAL Pro data state") val nowMs = snodeClock.currentTimeMillis() + // Refund-requested is now a synced config flag (set by whichever device — e.g. iOS — + // initiated the refund), not a get_pro_status field; read it for cross-device display. + val refundInProgress = configFactory.get() + .withUserConfigs { it.userProfile.getRefundRequested() != null } ProDataState( - type = proStatusState.lastUpdated?.first?.toProStatus(nowMs, application) ?: ProStatus.NeverSubscribed, + type = proStatusState.lastUpdated?.first?.toProStatus(nowMs, application, refundInProgress) ?: ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -462,126 +464,21 @@ class ProStatusManager @Inject constructor( } /** - * To be called once a subscription has successfully gone through a provider. - * This will link that payment to our back end. + * Called once a purchase has gone through the store. Redemption is now implicit: the store notifies + * the backend out-of-band and any master-signed request binds the account's unbound payments, so + * there is no "add payment" call anymore. We record a synced "purchase in flight" marker — so this + * device and the user's other devices know to poll — and schedule the proof worker, which retries + * generate_pro_proof with backoff until the backend has the payment and issues a proof (or the + * 1-week pro_prepaid gate expires). Setting the marker is a no-op inside libsession if already Pro, + * and it auto-clears once the entitlement lands. */ - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun addProPayment(orderId: String, paymentId: String) { - // max 3 attempts as per PRD - val maxAttempts = 3 - - // no point in going further if we have no key data - val keyData = loginState.loggedInState.value ?: throw Exception() - val rotatingKeyPair = ED25519.generate(null) - - for (attempt in 1..maxAttempts) { - try { - // 5s timeout as per PRD - val paymentResponse = runCatching { - requireNotNull(withTimeoutOrNull(5000) { - serverApiExecutor.execute( - ServerApiRequest( - proBackendConfig = backendConfig.get(), - api = addProPaymentApiFactory.create( - googlePaymentToken = paymentId, - googleOrderId = orderId, - masterPrivateKey = keyData.seeded.proMasterPrivateKey, - rotatingPrivateKey = rotatingKeyPair.secretKey.data - ) - ) - ) - }) { - "Timeout adding pro payment" - } - }.getOrElse { - // 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) { - is ProApiResponse.Success -> { - Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' successful") - // Payment was successfully claimed - save it - configFactory.get().withMutableUserConfigs { configs -> - configs.userProfile.setProConfig( - ProConfig( - // §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) - } - - // 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 -> { - Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' failure: $paymentResponse") - // §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) - ) - } - } - } - } catch (e: CancellationException) { - throw e - } catch (e: SubscriptionManager.PaymentServerException){ - // 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 - if (attempt < maxAttempts) { - // small incremental backoff before retry - val backoffMs = 300L * attempt - delay(backoffMs) - } - } + suspend fun onPurchaseInFlight() { + val nowSeconds = snodeClock.currentTime().epochSecond + configFactory.get().withMutableUserConfigs { configs -> + configs.userProfile.setProPrepaid(nowSeconds) } - - // All attempts failed - throw our custom exception - Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Backend 'add pro payment' - Al retries attempted, throwing our custom `PaymentServerException`") - throw SubscriptionManager.PaymentServerException() + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase in flight; set pro_prepaid, scheduling proof redemption") + ProProofGenerationWorker.schedule(application) } companion object { 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 deleted file mode 100644 index cd49a66050..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/api/AddProPaymentApi.kt +++ /dev/null @@ -1,38 +0,0 @@ -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.ProProofResponse -import network.loki.messenger.libsession_util.pro.ProRequest - -class AddProPaymentApi @AssistedInject constructor( - @Assisted("token") private val googlePaymentToken: String, - @Assisted private val googleOrderId: String, - @Assisted("master") private val masterPrivateKey: ByteArray, - @Assisted private val rotatingPrivateKey: ByteArray, - deps: ProApiDependencies -) : ProApi(deps) { - override fun buildProRequest(): ProRequest = - BackendRequests.buildAddProPaymentRequest( - masterPrivateKey = masterPrivateKey, - rotatingPrivateKey = rotatingPrivateKey, - providerCode = BackendRequests.PAYMENT_PROVIDER_GOOGLE_PLAY, - // Google composite payment_id = "|" (backend splits on first '|'). - paymentId = "$googlePaymentToken|$googleOrderId", - ) - - override fun parseResponse(json: String): ProProofResponse = - BackendRequests.parseAddPaymentResponse(json) - - @AssistedFactory - interface Factory { - fun create( - @Assisted("token") googlePaymentToken: String, - googleOrderId: String, - @Assisted("master") masterPrivateKey: ByteArray, - rotatingPrivateKey: ByteArray, - ): AddProPaymentApi - } -} 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 23caf40efa..a2ff63cf4e 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 @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.pro.ProStatusManager @@ -33,7 +34,6 @@ abstract class SubscriptionManager( data object Cancelled : PurchaseEvent sealed interface Failed : PurchaseEvent { data class GenericError(val errorMessage: String? = null): Failed - data class ServerError(val orderId: String, val paymentId: String) : Failed } } @@ -64,38 +64,20 @@ abstract class SubscriptionManager( * Function called when a purchased has been made successfully from the subscription api */ fun onPurchaseSuccessful(orderId: String, paymentId: String){ - // we need to tie our purchase with the back end + // The store accepted the payment; the backend redeems it out-of-band (no synchronous add-payment + // call anymore). Record the purchase as in-flight (synced) and kick off the proof-redemption poll. + // orderId/paymentId are no longer needed by the backend — kept only for the callback signature. scope.launch { try { - proStatusManager.addProPayment(orderId, paymentId) + proStatusManager.onPurchaseInFlight() _purchaseEvents.emit(PurchaseEvent.Success) } catch (e: Exception) { - when (e) { - is PaymentServerException -> { - _purchaseEvents.emit( - PurchaseEvent.Failed.ServerError( - orderId = orderId, - paymentId = paymentId - ) - ) - } - // 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()) - } + Log.e("SubscriptionManager", "Failed to record purchase in flight", e) + _purchaseEvents.emit(PurchaseEvent.Failed.GenericError()) } } } - 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/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 910f4085d2..be72f6130a 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 @@ -92,6 +92,14 @@ class PlayStoreSubscriptionManager @Inject constructor( return@setListener } + // Only a PURCHASED (not PENDING) purchase means Google accepted payment; a pending + // purchase (e.g. cash / parental approval) isn't redeemable yet, so don't mark it + // in-flight — we'll get another callback when it resolves to PURCHASED. + if (it.purchaseState != Purchase.PurchaseState.PURCHASED) { + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Ignoring purchase not in PURCHASED state: ${it.purchaseState}") + return@setListener + } + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Billing callback. We have a purchase [${it.orderId}]. Acknowledged? ${it.isAcknowledged}") From 9b777c2863eabe612a79c875b817e3659c305730 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Wed, 29 Jul 2026 23:31:00 -0300 Subject: [PATCH 2/8] Native codepoint counting; route char-limit policy through libsession - Count/truncate message codepoints natively (String.codePointCount / offsetByCodePoints) instead of the glue's utf16_count round-trip. - addProFeatures now feeds the native codepoint count to SessionProtocol.proFeaturesForMessage, letting libsession own the count -> higher-character-limit decision. - MAX_CHARACTER_PRO / MAX_CHARACTER_REGULAR are single-sourced from libsession (SessionProtocol.PRO_HIGHER/STANDARD_CHARACTER_LIMIT). --- .../sending_receiving/VisibleMessageHandler.kt | 7 +++++-- .../thoughtcrime/securesms/InputbarViewModel.kt | 4 ++-- .../securesms/pro/ProStatusManager.kt | 16 ++++++++++------ 3 files changed, 17 insertions(+), 10 deletions(-) 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 25e8e1597b..ce9aeadbc9 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 @@ -7,7 +7,6 @@ import network.loki.messenger.libsession_util.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.protocol.DecodedPro import network.loki.messenger.libsession_util.util.BaseCommunityInfo import network.loki.messenger.libsession_util.util.ExpiryMode -import network.loki.messenger.libsession_util.util.Util import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.jobs.AttachmentDownloadJob @@ -156,7 +155,11 @@ class VisibleMessageHandler @Inject constructor( // Verify the incoming message length and truncate it if needed, before saving it to the db val maxChars = proStatusManager.getIncomingMessageMaxLength(message) - val messageText = message.text?.let { Util.truncateCodepoints(it, maxChars) } // truncate to max char limit for this message + // truncate to max char limit for this message (codepoint-aware, native) + val messageText = message.text?.let { text -> + if (text.codePointCount(0, text.length) <= maxChars) text + else text.substring(0, text.offsetByCodePoints(0, maxChars)) + } message.text = messageText message.hasMention = (sequenceOf(ctx.currentUserPublicKey) + ctx.getCurrentUserBlindedIDsByThread(threadAddress).asSequence()) .any { key -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index 01a65e4b26..a60593f6db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import network.loki.messenger.R -import network.loki.messenger.libsession_util.util.Util import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.pro.ProStatus @@ -34,7 +33,8 @@ abstract class InputbarViewModel( fun onTextChanged(text: CharSequence) { // check the character limit val maxChars = proStatusManager.getCharacterLimit(currentUser.isPro) - val charsLeft = maxChars - Util.countCodepoints(text.toString()) + val message = text.toString() + val charsLeft = maxChars - message.codePointCount(0, message.length) // update the char limit state based on characters left val charLimitState = if(charsLeft <= CHARACTER_LIMIT_THRESHOLD){ 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 c5bee9b15b..46bdfa6c7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -41,7 +41,7 @@ import network.loki.messenger.libsession_util.protocol.ProFeature import network.loki.messenger.libsession_util.protocol.ProMessageFeature import network.loki.messenger.libsession_util.protocol.ProProfileFeature import network.loki.messenger.libsession_util.util.Conversation -import network.loki.messenger.libsession_util.util.Util +import network.loki.messenger.libsession_util.protocol.SessionProtocol import network.loki.messenger.libsession_util.util.asSequence import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.visible.VisibleMessage @@ -455,9 +455,12 @@ class ProStatusManager @Inject constructor( proFeatures += configs.userProfile.getProFeatures().asSequence() } - if (message is VisibleMessage && - Util.countCodepoints(message.text.orEmpty()) > MAX_CHARACTER_REGULAR){ - proFeatures += ProMessageFeature.HIGHER_CHARACTER_LIMIT + if (message is VisibleMessage) { + // Let libsession own the count -> feature policy: we count codepoints natively and + // hand it the count; it returns the message feature bitset (e.g. higher char limit). + val text = message.text.orEmpty() + SessionProtocol.proFeaturesForMessage(text.codePointCount(0, text.length)) + .toProMessageFeatures(proFeatures) } message.proFeatures = proFeatures @@ -482,8 +485,9 @@ class ProStatusManager @Inject constructor( } companion object { - const val MAX_CHARACTER_PRO = 10000 // max characters in a message for pro users - private const val MAX_CHARACTER_REGULAR = 2000 // max characters in a message for non pro users + // Single-sourced from libsession (see SessionProtocol) rather than hard-coded here. + val MAX_CHARACTER_PRO = SessionProtocol.PRO_HIGHER_CHARACTER_LIMIT // max message codepoints for pro users + private val MAX_CHARACTER_REGULAR = SessionProtocol.STANDARD_CHARACTER_LIMIT // max message codepoints for non-pro users const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" From 8cb7c0e91c30f07e5c47d5d997b62bc1ae3a443a Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 13:31:00 -0300 Subject: [PATCH 3/8] Drop client-side rotating-seed window characterization from comments Clients must not care about or assert libsession's rotating-seed rotation window (it's libsession-owned and may change). Reword the rotating-key comments to just 'the seed for now' instead of 'weekly'/'rotation period'; no code logic relied on the window. --- .../thoughtcrime/securesms/pro/ProProofGenerationWorker.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7cee4e4373..6dd7f9d138 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -69,8 +69,8 @@ class ProProofGenerationWorker @AssistedInject constructor( } return try { - // Rotating key is the deterministic weekly seed derived from the Pro master key (libsession - // owns the rotation schedule), so every device converges on the same key per rotation period + // Rotating key is the deterministic seed derived from the Pro master key for the current + // time (libsession owns the rotation schedule), so every device converges on the same key // instead of each generating a random one. Expand the 32-byte seed to a full keypair. val rotatingSeed = ED25519.proRotatingSeed(proMasterKey, snodeClock.currentTime().epochSecond) val rotatingPrivateKey = ED25519.generate(rotatingSeed).secretKey.data From b3d5cf64cba44116c0b620e4f99323762e1d5fd4 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 20:12:32 -0300 Subject: [PATCH 4/8] Pro renewal (android): drop jitter; E<-account_expiry; merge/downgrade guards Align android's worker-based renewal with the reviewed design (agent-comms/pro-proof-renewal-loop-design.md). Android was already renewal_target-driven + backoff-bounded (WorkManager) + revocation-clearing; the remaining gaps: - Drop the random(10..60min) pre-expiry jitter -> deterministic 60min lead. Per-device jitter leaks device count via the landed-renewal order statistic; libsession owns timing, config resolution settles concurrent renewals. - ProProofGenerationWorker: upgrade guard (replace proof iff newer expiry) + write E<-account_expiry from the proof response, so the renewal path keeps E fresh without a get_pro_status. - FetchProStatusWorker: downgrade guard on the Never/Expired clear (never wipe a still-valid proof a fresh sync just landed). --- .../securesms/pro/FetchProStatusWorker.kt | 9 ++++++++- .../securesms/pro/ProProofGenerationWorker.kt | 17 +++++++++++++---- .../securesms/pro/ProStatusManager.kt | 9 +++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt index 012a2abb59..227e6be44e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -102,7 +102,14 @@ class FetchProStatusWorker @AssistedInject constructor( if (details.userStatus == ProUserStatus.EXPIRED || details.userStatus == ProUserStatus.NEVER ) { - configs.userProfile.removeProConfig() + // Downgrade guard: never wipe a currently-valid proof (a stale status read vs a + // fresh proof another device just landed). At a genuine lapse the proof has also + // expired (proof.expiry <= account expiry by backend clamp), so this still passes. + val nowSeconds = snodeClock.currentTime().epochSecond + val proof = configs.userProfile.getProConfig()?.proProof + if (proof == null || proof.expirySeconds <= nowSeconds) { + configs.userProfile.removeProConfig() + } } } proDatabase.updateProStatus(proStatus = details, updatedAt = snodeClock.currentTime()) 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 6dd7f9d138..ec50c4b760 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -87,10 +87,19 @@ class ProProofGenerationWorker @AssistedInject constructor( // §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( - proProof = proof, - rotatingPrivateKey = rotatingPrivateKey)) + configFactory.withMutableUserConfigs { configs -> + // Upgrade guard: only replace the proof if it extends coverage (monotonic merge; + // same-period races round to the same expiry -> byte-identical -> no-op). Avoids + // churning a proof another device just landed. + val current = configs.userProfile.getProConfig()?.proProof + if (current == null || proof.expirySeconds > current.expirySeconds) { + configs.userProfile.setProConfig(ProConfig( + proProof = proof, + rotatingPrivateKey = rotatingPrivateKey)) + } + // Refresh the cached access-expiry from the advisory account_expiry that rides the + // proof response, so the renewal path keeps E fresh without a separate get_pro_status. + response.accountExpiry?.let { configs.userProfile.setProAccessExpiry(it.epochSecond) } } 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 46bdfa6c7f..85e91962e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -335,10 +335,11 @@ class ProStatusManager @Inject constructor( .distinctUntilChanged() .mapLatest { proConfig -> val expiry = Instant.ofEpochSecond(proConfig.proProof.expirySeconds) - // Schedule a refresh for a random number between 10 and 60 minutes before proof expiry - - val refreshTime = - expiry.minus(Duration.ofMinutes((10..60).random().toLong())) + // Wake ~1h before proof expiry so the renewal path runs. Deterministic + // (no client-side jitter): per-device random offsets leak device count + // via the landed-renewal order statistic; libsession owns the timing + // (renewal_target), and config resolution settles concurrent renewals. + val refreshTime = expiry.minus(Duration.ofMinutes(60)) snodeClock.delayUntil(refreshTime) "Pro proof expiry reached" From 1c256fd7b4f3137ed968b8f3d1ee84f6f44fabdc Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 20:25:25 -0300 Subject: [PATCH 5/8] Pro renewal (android): trigger redemption poll on a synced pro_prepaid change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage gap found while verifying the reconcile triggers: the status-refresh scheduler watched proAccessExpiry (E), the proof, status expiry, and startup — but nothing watched pro_prepaid (I). So a prepaid synced from another device's purchase never kicked this device's redemption poll, breaking the cross-device 'any device pulls the entitlement through' resilience (design 1.7). Watch (E, I) so a synced prepaid triggers a status refresh -> scheduleProofGenerationIfNeeded -> poll. --- .../org/thoughtcrime/securesms/pro/ProStatusManager.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt index 85e91962e3..455397ca39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -313,11 +313,16 @@ class ProStatusManager @Inject constructor( .userConfigsChanged(EnumSet.of(UserConfigType.USER_PROFILE)) .map { configFactory.get().withUserConfigs { configs -> - configs.userProfile.getProAccessExpiry() + // Watch both the access expiry (E) and the prepaid marker (I): a + // synced prepaid from another device's purchase must kick the + // redemption poll here too, so any device can pull the entitlement + // through even if the purchasing device goes offline before redeeming. + configs.userProfile.getProAccessExpiry() to + configs.userProfile.getProPrepaid() } } .distinctUntilChanged() - .map { "ProAccessExpiry in config changes" }, + .map { "ProAccessExpiry/prepaid in config changes" }, proStatusRepository.get().loadState .mapNotNull { it.lastUpdated?.first?.expiry } From 17129844b24bc43d376047718df6e5eed2cfd9ab Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 20:50:57 -0300 Subject: [PATCH 6/8] Pro (android): fix error_code slug expired -> subscription_expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProErrorCode is the generate_pro_proof error_code vocabulary; the backend emits subscription_expired there for a lapsed entitlement (base.ErrorCode), NOT expired. "expired" is a user_status value (never/active/expired) — a different wire field, already modelled correctly by ProUserStatus. The two vocabularies are deliberately disjoint so no token identifies two fields; ProErrorCode.EXPIRED="expired" borrowed the status token into the error_code enum, so it could never match a real slug and the actual lapse code was unmodelled. --- app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/api/ProApi.kt index 79595fedbb..55814e03c4 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 @@ -100,7 +100,7 @@ object ProErrorCode { const val BAD_SIGNATURE = "bad_signature" const val STALE_REQUEST = "stale_request" const val UNKNOWN_PAYMENT = "unknown_payment" - const val EXPIRED = "expired" + const val SUBSCRIPTION_EXPIRED = "subscription_expired" const val NOT_SUBSCRIBED = "not_subscribed" const val REVOKED = "revoked" const val INTERNAL_ERROR = "internal_error" From 61bcce21f5f3987100edcd0440f431a5acf635ce Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 22:38:05 -0300 Subject: [PATCH 7/8] Dev builds: allow libsession-util-android from source via local.properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composite-build hook (build the glue from source in one Gradle run with the app, for live-testing coordinated glue+app changes) previously required passing -Dsession.libsession_util.project.path on every invocation. Read the same glue-checkout path from local.properties too (gitignored, set once), so a plain ./gradlew builds glue-from-source for devs who opt in, while production/CI — with no such entry — keep using the published AAR. The -D system property still works and takes precedence for a one-off override. Requires the glue on AGP 9 (matched toolchain) so both share one Gradle run. --- settings.gradle.kts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 02374b0459..aa2aaa8473 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,8 +14,21 @@ rootProject.name = "session-android" includeBuild("build-logic") -// If libsession_util_project_path is set, include it as a build dependency +// Dev-only: build the glue (libsession-util-android) from source, in one Gradle run with the app, +// instead of pulling the prebuilt AAR — so coordinated glue+app changes can be live-tested locally. +// Production/CI builds leave this unset and use the published AAR. The glue checkout path may come from +// either (system property wins, so it can override the file for a one-off): +// * -Dsession.libsession_util.project.path= on the command line, or +// * session.libsession_util.project.path= in local.properties (gitignored, set-once) val libSessionUtilProjectPath: String = System.getProperty("session.libsession_util.project.path", "") + .ifBlank { + val localProps = file("local.properties") + if (localProps.exists()) { + java.util.Properties() + .apply { localProps.inputStream().use(::load) } + .getProperty("session.libsession_util.project.path", "") + } else "" + } if (libSessionUtilProjectPath.isNotBlank()) { include(":libsession-util-android") project(":libsession-util-android").projectDir = file(libSessionUtilProjectPath).resolve("library") From fb9952279b1e113450889bb7bd753c8667e79492 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 30 Jul 2026 22:50:47 -0300 Subject: [PATCH 8/8] Bump version to 1.34.0 (453) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index edb1ea4cb0..db206e3d3d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ configurations.configureEach { exclude(module = "commons-logging") } -val canonicalVersionCode = 451 -val canonicalVersionName = "1.33.4" +val canonicalVersionCode = 453 +val canonicalVersionName = "1.34.0" val postFixSize = 10 val abiPostFix = mapOf(