From cc9361af66de2815541dc4574a4ece2d7faec30f Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 24 Aug 2026 15:24:11 +1000 Subject: [PATCH 1/9] feat: route refunds through Session's own two links, and branch the non-originating screen Three connected corrections to the refund CTA. **The links.** The refund action used the provider's `refund_platform_url`/`refund_support_url`. It now uses two Session-owned short links chosen on the quick-refund window alone: `ProUrls.QUICK_REFUND` while the store will still take the request, `ProUrls.SUPPORT` once it will not. Being ours, the destinations can be repointed without a client release, and all three clients now agree on them. The old support value for Google Play redirected to the Play store, so a screen promising the request was handled by Session Support sent the user to the one place it cannot be actioned. **The non-originating screen now branches.** It served both the <48h and >48h states with one rendering: the former's button under the latter's link. The window was already resolved at the dispatch site and simply was not passed on, so this threads it through and branches the button, the body copy and the link together. Past the window the copy is `proPlanPlatformRefundLong`, which is the same sentence iOS already shows for this state. **The CTA names the store, not the platform.** `getPlatformDisplayName()` exists for exactly this and was already used by the cancel, choose-plan and no-billing screens; only the two refund screens still passed the raw platform name, rendering "Open Google Website" where the designs say "Open Google Play Store Website". iOS and Desktop both apply the same rule. Verified against a QA build: all four @android pro_refund_screens specs pass, and iOS and Desktop pass the same assertions against their own builds. Co-Authored-By: Claude Opus 5 --- .../prosettings/RefundPlanNonOriginating.kt | 30 +++++++++++++++---- .../prosettings/RefundPlanScreen.kt | 26 ++++++++++++---- .../org/thoughtcrime/securesms/pro/ProUrls.kt | 6 ++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index bb987d3b0f..c7c7d6b9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -15,6 +15,8 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.ProUrls +import org.thoughtcrime.securesms.pro.getPlatformDisplayName import org.thoughtcrime.securesms.pro.previewAutoRenewingApple import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -24,6 +26,10 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun RefundPlanNonOriginating( subscription: ProStatus.Active.WithPlan, + /// Whether the store's own quick-refund window is still open. Decides all three of the button, the + /// body copy and the link, exactly as it does on the originating screen — while the store will take + /// the request it is sent there, and once it will not, only Session can action it. + isQuickRefund: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ @@ -34,18 +40,29 @@ fun RefundPlanNonOriginating( disabled = true, onBack = onBack, headerTitle = stringResource(R.string.proRefundDescription), - buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, subscription.providerData.platform) - .format().toString(), + buttonText = if (isQuickRefund) + // See RefundPlanScreen: "Google Play Store", not "Google". + Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, subscription.providerData.getPlatformDisplayName()) + .format().toString() + else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - sendCommand(ShowOpenUrlDialog(subscription.providerData.refundSupportUrl)) + sendCommand( + ShowOpenUrlDialog(if (isQuickRefund) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) + ) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) .format().toString(), - contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) + // Past the window the request is Session's to handle, and the copy has to say so — this is the + // same sentence the originating screen shows, with the non-originating premise kept. + contentDescription = if (isQuickRefund) + Phrase.from(context.getText(R.string.proPlanPlatformRefund)) + .put(PLATFORM_STORE_KEY, subscription.providerData.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) + .format() + else Phrase.from(context.getText(R.string.proPlanPlatformRefundLong)) .put(PLATFORM_STORE_KEY, subscription.providerData.store) - .put(PLATFORM_ACCOUNT_KEY, subscription.providerData.platformAccount) .format(), linkCellsInfo = stringResource(R.string.refundRequestOptions), linkCells = listOf( @@ -88,6 +105,7 @@ private fun PreviewUpdatePlan( val context = LocalContext.current RefundPlanNonOriginating ( subscription = previewAutoRenewingApple, + isQuickRefund = true, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt index 9349b32977..ff98abc4e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -20,6 +20,8 @@ import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatus +import org.thoughtcrime.securesms.pro.ProUrls +import org.thoughtcrime.securesms.pro.getPlatformDisplayName import org.thoughtcrime.securesms.pro.isFromAnotherPlatform import org.thoughtcrime.securesms.pro.previewAutoRenewingApple import org.thoughtcrime.securesms.ui.qaTag @@ -66,6 +68,10 @@ fun RefundPlanScreen( || !refundData.hasValidSubscription -> RefundPlanNonOriginating( subscription = activePlan, + // The window governs this screen too, and it was resolved right here — this branch + // simply never passed it on, so one screen served both the <48h and >48h states with + // the former's button and the latter's link. + isQuickRefund = refundData.isQuickRefund, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -98,16 +104,24 @@ fun RefundPlan( disabled = true, onBack = onBack, buttonText = if(isQuickRefund) Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, data.providerData.platform) + // `getPlatformDisplayName`, not `platform`: the designs spell the rule out - Apple reads + // "Apple" but our own store reads "Google Play Store", never "Google". iOS and Desktop + // both branch the same way; these two refund screens were the only sites still passing + // the raw platform name. + .put(PLATFORM_KEY, data.providerData.getPlatformDisplayName()) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - if(isQuickRefund && !quickRefundUrl.isNullOrEmpty()){ - sendCommand(ShowOpenUrlDialog(quickRefundUrl)) - } else { - sendCommand(ShowOpenUrlDialog(data.providerData.refundSupportUrl)) - } + // Two Session-owned links, chosen on the window alone - not the provider's own + // `refund_platform_url`/`refund_support_url`. Being ours, the destinations can be + // repointed without a client release, and all three clients agree on them. + // + // The window is what decides who can act: while it is open the store takes the request, + // and once it closes only Session can, which is what this screen's copy promises. + sendCommand( + ShowOpenUrlDialog(if (isQuickRefund) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) + ) }, title = stringResource(R.string.proRefundDescription), ){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt index cdf9c62a22..911e5296bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt @@ -13,6 +13,12 @@ package org.thoughtcrime.securesms.pro */ object ProUrls { const val FAQ = "https://getsession.org/pro#faq" + /** + * The refund route while the store's own quick-refund window is open. A Session-owned short link + * that redirects to the store, so the destination can change without a client release - which is + * why the CTA beside it names the store while this url does not. + */ + const val QUICK_REFUND = "https://getsession.org/android-refund" const val PRIVACY_POLICY = "https://getsession.org/pro-privacy" const val ROADMAP = "https://getsession.org/pro#roadmap" const val SUPPORT = "https://getsession.org/pro-support" From fcfd0471f705cc4fd524918ed161ae1e2b8f99d0 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 24 Aug 2026 16:19:48 +1000 Subject: [PATCH 2/9] fix: only offer the store refund route for a plan bought on Google Play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-refund link redirects into the Play store, so it is only a usable route for a plan bought there. An App Store plan reports its refund window open for the whole subscription, so gating on the window alone sent an Apple subscriber to the wrong store's refund flow — reachable on the non-originating screen, which is the one that shows plans bought elsewhere. Gated on the provider slug as well as the window. iOS and Desktop take the same gate. Co-Authored-By: Claude Opus 5 --- .../prosettings/RefundPlanNonOriginating.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index c7c7d6b9a9..56e1eeb509 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -14,6 +14,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACC import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY 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.ProUrls import org.thoughtcrime.securesms.pro.getPlatformDisplayName @@ -48,8 +49,15 @@ fun RefundPlanNonOriginating( else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { + // Gated on the originating store as well as the window: the quick-refund link is Google + // Play's and redirects into the Play store, so it is only a usable route for a plan bought + // there. An App Store plan reports its window open for the whole subscription, so gating on + // the window alone would send an Apple subscriber to the wrong store's refund flow — which + // this screen, showing only non-originating plans, is where that would happen. + val canUseStoreRoute = isQuickRefund && + subscription.providerData.slug == PAYMENT_PROVIDER_GOOGLE_PLAY sendCommand( - ShowOpenUrlDialog(if (isQuickRefund) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) + ShowOpenUrlDialog(if (canUseStoreRoute) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) ) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) From 8a6a08c026182ed4226c22308edd535ba403bef5 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 24 Aug 2026 16:28:14 +1000 Subject: [PATCH 3/9] refactor: read the store refund url from libsession instead of copying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession owns the per-provider url table and says so in `pro_backend.h`: "libsession owns them as the single source of truth rather than each client duplicating them". The store refund route now reads `providerUrls(slug).refundSupportUrl` through the wrapper this app already used for the other four provider urls, and the copy added earlier is gone. Worth knowing for anyone reading the call site: the slot's NAME is misleading. For Google Play, libsession's `refund_support_url` is the Session short link that redirects into the Play store — so the value the window-OPEN route needs sits under libsession's "support" name, while the window-CLOSED route uses `ProUrls.SUPPORT`, which mirrors `url_pro_support`. Those two are different destinations with confusingly similar names. `ProUrls.SUPPORT` stays a copy: the `url_pro_*` registry is a C struct of `const char*` with no Kotlin accessor, which is what that file already documents. Verified against a QA build: all four @android pro_refund_screens specs pass. Co-Authored-By: Claude Opus 5 --- .../prosettings/RefundPlanNonOriginating.kt | 11 ++++++++++- .../preferences/prosettings/RefundPlanScreen.kt | 12 +++++++++++- .../java/org/thoughtcrime/securesms/pro/ProUrls.kt | 6 ------ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index 56e1eeb509..8dc28c177d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -56,8 +56,17 @@ fun RefundPlanNonOriginating( // this screen, showing only non-originating plans, is where that would happen. val canUseStoreRoute = isQuickRefund && subscription.providerData.slug == PAYMENT_PROVIDER_GOOGLE_PLAY + // The store route's url is libsession's, read through `providerUrls` — it owns the + // per-provider table and says so. Note the slot: for Google Play its `refund_support_url` + // IS the Session short link that redirects into the Play store, so the value we want for + // the window-OPEN route sits under libsession's "support" name. The window-closed route + // uses `ProUrls.SUPPORT` instead, which mirrors `url_pro_support` — that one has no Kotlin + // accessor, which is the only reason it is still a copy. sendCommand( - ShowOpenUrlDialog(if (canUseStoreRoute) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) + ShowOpenUrlDialog( + if (canUseStoreRoute) subscription.providerData.refundSupportUrl + else ProUrls.SUPPORT + ) ) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt index ff98abc4e3..9492dcc050 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -119,8 +119,18 @@ fun RefundPlan( // // The window is what decides who can act: while it is open the store takes the request, // and once it closes only Session can, which is what this screen's copy promises. + // The store route's url is libsession's, read through `providerUrls` — it owns the + // per-provider table and says so. Note the slot: for Google Play its `refund_support_url` + // IS the Session short link that redirects into the Play store, so the value we want for + // the window-OPEN route sits under libsession's "support" name. The window-closed route + // uses `ProUrls.SUPPORT` instead, which mirrors `url_pro_support` — that one has no Kotlin + // accessor, which is the only reason it is still a copy. + // + // No provider gate here: this screen only ever shows a plan bought on this store. sendCommand( - ShowOpenUrlDialog(if (isQuickRefund) ProUrls.QUICK_REFUND else ProUrls.SUPPORT) + ShowOpenUrlDialog( + if (isQuickRefund) data.providerData.refundSupportUrl else ProUrls.SUPPORT + ) ) }, title = stringResource(R.string.proRefundDescription), diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt index 911e5296bd..cdf9c62a22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt @@ -13,12 +13,6 @@ package org.thoughtcrime.securesms.pro */ object ProUrls { const val FAQ = "https://getsession.org/pro#faq" - /** - * The refund route while the store's own quick-refund window is open. A Session-owned short link - * that redirects to the store, so the destination can change without a client release - which is - * why the CTA beside it names the store while this url does not. - */ - const val QUICK_REFUND = "https://getsession.org/android-refund" const val PRIVACY_POLICY = "https://getsession.org/pro-privacy" const val ROADMAP = "https://getsession.org/pro#roadmap" const val SUPPORT = "https://getsession.org/pro-support" From aed084f74f345553c9f49fe7cd56b99ef55b191a Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 24 Aug 2026 16:50:30 +1000 Subject: [PATCH 4/9] fix: drop the redundant originating-provider check on the refund route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added in the previous commit to stop an Apple plan reaching the Play-store link — a case that cannot happen. `providerData` is the ORIGINATING provider's table, so an Apple plan already yields Apple's own refund page there. The check replaced that correct page with Session's support form, which contradicts the design's "Apple processes all refund requests, we do not get a say". The window alone is the right condition; the per-provider behaviour falls out of libsession's table rather than a client check. Verified against a QA build: all four @android pro_refund_screens specs pass. Co-Authored-By: Claude Opus 5 --- .../prosettings/RefundPlanNonOriginating.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index 8dc28c177d..72651e3366 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -14,7 +14,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACC import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY 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.ProUrls import org.thoughtcrime.securesms.pro.getPlatformDisplayName @@ -49,22 +48,20 @@ fun RefundPlanNonOriginating( else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - // Gated on the originating store as well as the window: the quick-refund link is Google - // Play's and redirects into the Play store, so it is only a usable route for a plan bought - // there. An App Store plan reports its window open for the whole subscription, so gating on - // the window alone would send an Apple subscriber to the wrong store's refund flow — which - // this screen, showing only non-originating plans, is where that would happen. - val canUseStoreRoute = isQuickRefund && - subscription.providerData.slug == PAYMENT_PROVIDER_GOOGLE_PLAY // The store route's url is libsession's, read through `providerUrls` — it owns the // per-provider table and says so. Note the slot: for Google Play its `refund_support_url` // IS the Session short link that redirects into the Play store, so the value we want for // the window-OPEN route sits under libsession's "support" name. The window-closed route // uses `ProUrls.SUPPORT` instead, which mirrors `url_pro_support` — that one has no Kotlin // accessor, which is the only reason it is still a copy. + // + // No provider check on top of the window: `providerData` is already the ORIGINATING + // provider's table, so an Apple plan yields Apple's own refund page here rather than the + // Play-store link. Gating on the provider as well would replace that correct page with + // Session's form. sendCommand( ShowOpenUrlDialog( - if (canUseStoreRoute) subscription.providerData.refundSupportUrl + if (isQuickRefund) subscription.providerData.refundSupportUrl else ProUrls.SUPPORT ) ) From 599af3197ac8f3b8f1e56394c18d56f1c6f76c50 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 25 Aug 2026 10:06:39 +1000 Subject: [PATCH 5/9] refactor: read the Pro support url from libsession instead of copying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProUrls.SUPPORT` now reads `BackendRequests.proUrls().support` rather than restating `https://getsession.org/pro-support`. libsession owns the registry; this file has documented since it was written that it copies only because no Kotlin accessor existed, and that "every value here was once wrong at the use site that needed it." The accessor is added in session-foundation/libsession-util-android#50. A getter, not a `val`: the accessor is JNI, so evaluating it while this `object` initialises could run before `System.loadLibrary("session_util")`. Reading per call is a struct field lookup and cannot be too early. **This does not compile against the published AAR** (`1.1.0-49-g5dbfffc`) — `proUrls()` does not exist there — so CI on this PR will fail until #50 merges and a release is cut and the version bumped here. Pushed anyway for review, deliberately. Verified against a local build: the app was compiled with `session.libsession_util.project.path` pointing at that glue branch, and the full `@android @pro` suite passed 29/29 on a device, including the refund specs that open this url. The other four `ProUrls` values are still literals; converting them is a follow-up once the accessor ships. Co-Authored-By: Claude Opus 5 --- .../org/thoughtcrime/securesms/pro/ProUrls.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt index cdf9c62a22..51f08c4ec6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt @@ -1,20 +1,27 @@ package org.thoughtcrime.securesms.pro +import network.loki.messenger.libsession_util.pro.BackendRequests + /** - * The Pro destinations, mirroring libsession's URL registry - * (`session_protocol.cpp`, the `url_pro_*` fields). - * - * Copies rather than reads: the registry is a C struct of `const char*` with no accessor exposed to - * Kotlin, so consuming it directly would mean adding JNI surface for five constants. + * The Pro destinations, from libsession's URL registry (`session_protocol.cpp`, the `url_pro_*` fields). * - * Being copies, they can drift from it, and they have — every value here was once wrong at the use site - * that needed it. They are defined together so that comparing this file against the registry is the whole - * check; correcting a single link where it happens to be used is what let them diverge one at a time. + * These were copies, because the registry is a C struct of `const char*` with no accessor exposed to + * Kotlin. Being copies they drifted — every value here was once wrong at the use site that needed it. + * The glue now exposes the registry ([BackendRequests.proUrls]), so a value here should READ it rather + * than restate it, and the remaining literals below are just the ones not yet converted. */ object ProUrls { const val FAQ = "https://getsession.org/pro#faq" const val PRIVACY_POLICY = "https://getsession.org/pro-privacy" const val ROADMAP = "https://getsession.org/pro#roadmap" - const val SUPPORT = "https://getsession.org/pro-support" + /** + * Read from libsession rather than copied, now that the glue exposes the registry + * ([BackendRequests.proUrls]). + * + * A getter, not a `val`: the accessor is JNI, so evaluating it while this `object` initialises + * could run before `System.loadLibrary("session_util")`. Reading per call costs a struct field + * lookup and cannot be too early. + */ + val SUPPORT: String get() = BackendRequests.proUrls().support const val TERMS_OF_SERVICE = "https://getsession.org/pro-terms" } From 65df6458f590f615c333c027d8ca8542b3ce774f Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 25 Aug 2026 11:29:45 +1000 Subject: [PATCH 6/9] Pro: drop the proof's version field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit libsession-util removed `ProProof::version` entirely (PR #130, f0b9ab23), so `proProof.version` no longer exists and this stopped compiling against the new glue. The field was redundant rather than merely unused. A proof's format is bound into its signature by the 16-byte domain prefix the signer selects (`ProProof_v0_____`) — the version was never part of the signed payload, so it attested to nothing and could disagree with the format the signature actually covers. Verification derives the format from the prefix instead. Protobuf field tag 1 on `ProProof` is retired and must never be reused: peers built before the removal still write a `uint32` there, so a new field on tag 1 would be handed their stale version numbers. Those peers degrade rather than break — libsession's companion change reports such a proof as `ProStatus::UnsupportedVersion`, which delivers the message without Pro content instead of dropping it. Nothing else in this repo referenced a proof version. Android does not vendor `SessionProtos.proto`; the generated `org.session.protos` classes come from the libsession-util-android artifact, so the proto side of the removal arrives with the dependency bump and needs no change here. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/thoughtcrime/securesms/pro/ProProofs.kt | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 9014dad6a9..46c73fce8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofs.kt @@ -6,11 +6,22 @@ import org.session.protos.SessionProtos /** * Copies values from a libsession ProProof into a protobuf-based ProProof. + * + * There is deliberately no version field. A proof's format is bound into its signature by the + * 16-byte domain prefix the signer selects (`ProProof_v0_____`), so the version was never part of + * the signed payload — carrying it alongside as a plain value proved nothing and could disagree + * with the format the signature actually attests to. Verification derives the format from the + * prefix instead, and libsession-util has dropped `ProProof::version` accordingly. + * + * Protobuf field tag 1 on `ProProof` is retired and must never be reused: peers built before the + * removal still put a `uint32` there, so a new field on tag 1 would be fed their stale version + * numbers. A peer that sends one is handled by libsession, which reports the proof as + * `ProStatus::UnsupportedVersion` and lets the message through without Pro content rather than + * dropping it. */ fun SessionProtos.ProProof.Builder.copyFromLibSession( proProof: ProProof -): SessionProtos.ProProof.Builder = setVersion(proProof.version) - .setExpiryUnixTs(proProof.expirySeconds) +): SessionProtos.ProProof.Builder = setExpiryUnixTs(proProof.expirySeconds) .setRevocationTag(ByteString.copyFrom(proProof.revocationTagHex.hexToByteArray())) .setRotatingPublicKey(ByteString.copyFrom(proProof.rotatingPubKeyHex.hexToByteArray())) .setSig(ByteString.copyFrom(proProof.signatureHex.hexToByteArray())) From 85a480002487f5645ae79986ea37305c95419ebb Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 25 Aug 2026 11:40:28 +1000 Subject: [PATCH 7/9] build: bump libsession-util-android to 1.1.0-51-g7021112 Picks up libsession-util-android#50, which is what supplies BackendRequests.proUrls(). The previous pin (1.1.0-49-g5dbfffc) predates it, so CI on this branch failed to resolve the symbol - the client half of this change could not build until the glue was released. Verified against the published artifact rather than the source override: pulled the .aar off oxen.rocks and confirmed `public final native ProUrls proUrls()` plus all eight getters, then compiled :app:compilePlayAutomaticQaKotlin with session.libsession_util.project.path commented out of local.properties, so the build resolved the maven artifact the way CI will. Co-Authored-By: Claude Opus 5 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae22a8c3bb..374ef6232b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ kotlinxImmutableVersion = "0.5.1" kryoVersion = "5.6.2" kspVersion = "2.3.11" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.1.0-49-g5dbfffc" +libsessionUtilAndroidVersion = "1.1.0-51-g7021112" media3ExoplayerVersion = "1.10.0" mockitoCoreVersion = "5.23.0" navVersion = "2.9.8" From 95454fb77bd5391b642a7da85d8e04f1e97eb9c3 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 25 Aug 2026 12:26:29 +1000 Subject: [PATCH 8/9] build: bump libsession-util-android to 1.1.0-53-gc8906a1 Picks up libsession-util-android#51, which drops ProProof::version and re-pins the vendored libsession-util from 194752f0 to ce827173. That makes the ProProofs.kt change already on this branch (#2185) load-bearing rather than merely tidy: with the field gone from the glue, the old `setVersion(proProof.version)` no longer compiles at all. Verified against the published artifact, not the local source override: the .aar off oxen.rocks has no `version` accessor on ProProof and still carries BackendRequests.proUrls() from #50, and :app:compilePlayAutomaticQaKotlin is green with session.libsession_util.project.path commented out of local.properties. Co-Authored-By: Claude Opus 5 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 374ef6232b..aa73cabd63 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,7 +30,7 @@ kotlinxImmutableVersion = "0.5.1" kryoVersion = "5.6.2" kspVersion = "2.3.11" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.1.0-51-g7021112" +libsessionUtilAndroidVersion = "1.1.0-53-gc8906a1" media3ExoplayerVersion = "1.10.0" mockitoCoreVersion = "5.23.0" navVersion = "2.9.8" From b6a7ab631702560072f578ef890a14b6bd6bf7b6 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 25 Aug 2026 12:34:38 +1000 Subject: [PATCH 9/9] refactor: read every Pro url from libsession, not just the support one FAQ, PRIVACY_POLICY, ROADMAP and TERMS_OF_SERVICE were still copies of libsession's `url_pro_*` registry, kept only because the glue exposed no accessor. It does now (BackendRequests.proUrls(), libsession-util-android#50), so they read it like SUPPORT already did and can no longer drift from it. Each is a `val` with a getter rather than a `const val`, and that is load-bearing: a `const val` cannot hold a JNI-read value at all, and evaluating the accessor while this `object` initialises can run before System.loadLibrary("session_util"). Reading per call costs one struct field lookup. Every call site passes these as ordinary arguments - none needs a compile-time constant - so dropping `const` compiles. Also corrects a claim in the two refund screens that `url_pro_support` "has no Kotlin accessor, which is the only reason it is still a copy". That stopped being true when SUPPORT became a getter, and is now wrong for all five. Co-Authored-By: Claude Opus 5 --- .../prosettings/RefundPlanNonOriginating.kt | 3 +-- .../prosettings/RefundPlanScreen.kt | 3 +-- .../org/thoughtcrime/securesms/pro/ProUrls.kt | 24 ++++--------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt index 72651e3366..bb1d973df2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -52,8 +52,7 @@ fun RefundPlanNonOriginating( // per-provider table and says so. Note the slot: for Google Play its `refund_support_url` // IS the Session short link that redirects into the Play store, so the value we want for // the window-OPEN route sits under libsession's "support" name. The window-closed route - // uses `ProUrls.SUPPORT` instead, which mirrors `url_pro_support` — that one has no Kotlin - // accessor, which is the only reason it is still a copy. + // uses `ProUrls.SUPPORT`, which reads libsession's `url_pro_support`. // // No provider check on top of the window: `providerData` is already the ORIGINATING // provider's table, so an Apple plan yields Apple's own refund page here rather than the diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt index 9492dcc050..c3ea7d486a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -123,8 +123,7 @@ fun RefundPlan( // per-provider table and says so. Note the slot: for Google Play its `refund_support_url` // IS the Session short link that redirects into the Play store, so the value we want for // the window-OPEN route sits under libsession's "support" name. The window-closed route - // uses `ProUrls.SUPPORT` instead, which mirrors `url_pro_support` — that one has no Kotlin - // accessor, which is the only reason it is still a copy. + // uses `ProUrls.SUPPORT`, which reads libsession's `url_pro_support`. // // No provider gate here: this screen only ever shows a plan bought on this store. sendCommand( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt index 51f08c4ec6..6a4e17d084 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProUrls.kt @@ -2,26 +2,10 @@ package org.thoughtcrime.securesms.pro import network.loki.messenger.libsession_util.pro.BackendRequests -/** - * The Pro destinations, from libsession's URL registry (`session_protocol.cpp`, the `url_pro_*` fields). - * - * These were copies, because the registry is a C struct of `const char*` with no accessor exposed to - * Kotlin. Being copies they drifted — every value here was once wrong at the use site that needed it. - * The glue now exposes the registry ([BackendRequests.proUrls]), so a value here should READ it rather - * than restate it, and the remaining literals below are just the ones not yet converted. - */ object ProUrls { - const val FAQ = "https://getsession.org/pro#faq" - const val PRIVACY_POLICY = "https://getsession.org/pro-privacy" - const val ROADMAP = "https://getsession.org/pro#roadmap" - /** - * Read from libsession rather than copied, now that the glue exposes the registry - * ([BackendRequests.proUrls]). - * - * A getter, not a `val`: the accessor is JNI, so evaluating it while this `object` initialises - * could run before `System.loadLibrary("session_util")`. Reading per call costs a struct field - * lookup and cannot be too early. - */ + val FAQ: String get() = BackendRequests.proUrls().faq + val PRIVACY_POLICY: String get() = BackendRequests.proUrls().privacyPolicy + val ROADMAP: String get() = BackendRequests.proUrls().roadmap val SUPPORT: String get() = BackendRequests.proUrls().support - const val TERMS_OF_SERVICE = "https://getsession.org/pro-terms" + val TERMS_OF_SERVICE: String get() = BackendRequests.proUrls().termsOfService }