Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
236568f
Pro: let a QA build force a successful status refresh
mpretty-cyro Aug 14, 2026
904cd4d
Pro: fetch on startup when a proof exists without an access expiry
mpretty-cyro Aug 14, 2026
1f367c7
Pro: only refresh status when the synced access expiry actually changes
mpretty-cyro Aug 14, 2026
baebc0a
Pro: don't warn about an expiry the backend has not confirmed
mpretty-cyro Aug 14, 2026
94563c3
Pro: don't refresh status when the settings list opens
mpretty-cyro Aug 14, 2026
b6463d5
Add an accessibility id to the Pro settings row's title
mpretty-cyro Aug 14, 2026
baf5680
Pro: derive our own ACCESS from the proof, and read it where it is en…
mpretty-cyro Aug 14, 2026
edfbbbc
QA: split the Pro status mock so DISPLAY can be mocked without granti…
mpretty-cyro Aug 14, 2026
bc79d33
Pro: say payment-due, not paid-through, for the reported expiry
mpretty-cyro Aug 14, 2026
225b4bd
QA: one lever per fact — proBackendStatus drives DISPLAY, sessionProP…
mpretty-cyro Aug 14, 2026
7a5e7c2
QA: an absent sessionProProof clears the override rather than leaving it
mpretty-cyro Aug 14, 2026
173e92d
Pro: the compose limit is ACCESS, but the dialog that explains it is …
mpretty-cyro Aug 14, 2026
c17185e
Pro: the upsell affordances read DISPLAY, not inverted ACCESS
mpretty-cyro Aug 14, 2026
64ed616
Pro: route the second pin gate through the ACCESS function too
mpretty-cyro Aug 14, 2026
4566c94
Pro: split ProStatus.Active so a dateless Active is expressible
mpretty-cyro Aug 14, 2026
6adf62a
Pro: seed DISPLAY from the proof, unfloor the expiry duration, and gi…
mpretty-cyro Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import org.thoughtcrime.securesms.api.swarm.execute
import org.thoughtcrime.securesms.auth.LoginStateRepository
import org.thoughtcrime.securesms.database.RecipientRepository
import org.thoughtcrime.securesms.dependencies.ManagerScope
import org.thoughtcrime.securesms.pro.ProStatusManager
import org.thoughtcrime.securesms.pro.copyFromLibSession
import org.thoughtcrime.securesms.service.ExpiringMessageManager
import javax.inject.Inject
Expand All @@ -78,6 +79,7 @@ class MessageSender @Inject constructor(
@param:ManagerScope private val scope: CoroutineScope,
private val loginStateRepository: LoginStateRepository,
private val jobQueue: Provider<JobQueue>,
private val proStatusManager: Provider<ProStatusManager>,
) {

// Error
Expand Down Expand Up @@ -137,9 +139,13 @@ class MessageSender @Inject constructor(

msg.toProto(builder, messageDataProvider)

// Attach pro proof
val proProof = configFactory.withUserConfigs { it.userProfile.getProConfig() }?.proProof
if (proProof != null && proProof.expirySeconds > snodeClock.currentTimeMillis() / 1000) {
// Attach pro proof.
//
// Routed through the one ACCESS function rather than checking expiry here: what we attach
// when sending IS an access decision, and it previously honoured expiry but NOT revocation,
// so a revoked proof we already knew about still went out on the wire.
val proProof = proStatusManager.get().currentUserProProofForAccess()
if (proProof != null) {
builder.proMessageBuilder.proofBuilder.copyFromLibSession(proProof)
} else {
// If we don't have any valid pro proof, clear the pro message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ interface TextSecurePreferences {
fun setDebugMessageFeatures(features: Set<ProFeature>)

fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus?

/**
* Mocked Pro ACCESS override: `true` grants, `false` DENIES, `null` means no override so the real
* proof governs. Tri-state deliberately — `false` and `null` differ when a real proof exists, which
* it can on a QA backend that mints them.
*/
fun getDebugProAccessOverride(): Boolean?
fun setDebugProAccessOverride(granted: Boolean?)
fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?)
fun getDebugProAccessExpiry(): Instant?
fun setDebugProAccessExpiry(expiry: Instant?)
Expand Down Expand Up @@ -386,6 +394,7 @@ interface TextSecurePreferences {
const val DEBUG_PRO_MESSAGE_FEATURES = "debug_pro_message_features"
const val DEBUG_PRO_PROFILE_FEATURES = "debug_pro_profile_features"
const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status"
const val DEBUG_PRO_ACCESS_OVERRIDE = "debug_pro_access_override"
const val DEBUG_PRO_ACCESS_EXPIRY = "debug_pro_access_expiry"
const val DEBUG_PRO_PLAN_STATUS = "debug_pro_plan_status"
const val DEBUG_FORCE_NO_BILLING = "debug_pro_has_billing"
Expand Down Expand Up @@ -1247,6 +1256,14 @@ class AppTextSecurePreferences @Inject constructor(
_events.tryEmit(TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS)
}

override fun getDebugProAccessOverride(): Boolean? =
getStringPreference(TextSecurePreferences.DEBUG_PRO_ACCESS_OVERRIDE, null)?.toBooleanStrictOrNull()

override fun setDebugProAccessOverride(granted: Boolean?) {
setStringPreference(TextSecurePreferences.DEBUG_PRO_ACCESS_OVERRIDE, granted?.toString())
_events.tryEmit(TextSecurePreferences.DEBUG_PRO_ACCESS_OVERRIDE)
}

override fun getDebugProAccessExpiry(): Instant? {
return getStringPreference(TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY, null)
?.toLongOrNull()
Expand Down
86 changes: 73 additions & 13 deletions app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package org.thoughtcrime.securesms

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import org.session.libsession.utilities.Phrase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY
Expand All @@ -28,21 +32,58 @@ abstract class InputbarViewModel(
private val _inputBarStateDialogsState = MutableStateFlow(InputBarDialogsState())
val inputBarStateDialogsState: StateFlow<InputBarDialogsState> = _inputBarStateDialogsState

private val currentUser by lazy { recipientRepository.getSelf() }
/**
* ACCESS ("what may this device do") for the character limit — which gates SENDING, not just what
* the composer displays.
*
* Deliberately NOT `by lazy`. ACCESS is validated against proof expiry and the cached revocation
* list on every resolve, and a value captured once at first use is only ever validated once: a
* revocation landing while this screen is open could not demote us until the ViewModel was
* recreated. `observeSelf()`'s change sources include the revocation notification and a timer armed
* at the earliest proof expiry, so this stays current for the life of the screen.
*
* A `StateFlow` rather than a `getSelf()` call per use because [onTextChanged] runs on every
* keystroke and `getSelf()` is an uncached fetch that takes the config lock.
*/
private val isSelfPro: StateFlow<Boolean> = recipientRepository.observeSelf()
.map { it.isPro }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
// Seeded synchronously so the very first keystroke is already correct rather than briefly
// reading as non-Pro. Guarded because `getSelf()` throws when not logged in.
initialValue = runCatching { recipientRepository.getSelf().isPro }.getOrDefault(false),
)

/**
* The composed length, kept so [validateMessageLength] can recompute against a FRESH access read
* instead of trusting the limit that was in force when the indicator was last drawn.
*
* A length is not an access decision, so caching it is safe; caching the limit is not.
*/
private var composedCodePointCount: Int = 0

fun onTextChanged(text: CharSequence) {
// check the character limit
val maxChars = proStatusManager.getCharacterLimit(currentUser.isPro)
// RENDERING: the observed value. This runs per keystroke, so it must not take the config lock.
val maxChars = proStatusManager.getCharacterLimit(isSelfPro.value)
val message = text.toString()
val charsLeft = maxChars - message.codePointCount(0, message.length)
composedCodePointCount = message.codePointCount(0, message.length)
val charsLeft = maxChars - composedCodePointCount

// update the char limit state based on characters left
val charLimitState = if(charsLeft <= CHARACTER_LIMIT_THRESHOLD){
InputBarCharLimitState(
count = charsLeft,
countFormatted = NumberUtil.getFormattedNumber(charsLeft.toLong()),
danger = charsLeft < 0,
showProBadge = !currentUser.isPro // only show the badge for non pro users
// THE RULE: the gate reads ACCESS, the thing that EXPLAINS the gate reads DISPLAY.
//
// This badge is an upsell, not an entitlement indicator, so it reads the plan's state.
// Do not "fix" it back to an inverted ACCESS read to match the other badges: a
// subscriber whose proof has not arrived is correctly held to the standard limit by
// ACCESS above, and inviting them to buy what they already pay for is a different
// question with a different answer.
showProBadge = proStatusManager.proDataState.value.type !is ProStatus.Active
)
} else {
null
Expand All @@ -51,12 +92,28 @@ abstract class InputbarViewModel(
_inputBarState.update { it.copy(charLimitState = charLimitState) }
}

/**
* ENFORCEMENT, so it calls the ACCESS function directly and unmemoized rather than reading the
* observed value or the indicator's cached count.
*
* This gates SENDING, which makes it a grant rather than a draw: a proof that expired or was revoked
* since the indicator was last drawn must refuse here, and it cannot if the decision is inherited
* from render state. Recomputed from [composedCodePointCount] for the same reason — the stored
* `charLimitState.count` was computed against whatever limit was in force at the last keystroke.
*/
fun validateMessageLength(): Boolean {
// the message is too long if we have a negative char left in the input state
val charsLeft = _inputBarState.value.charLimitState?.count ?: 0
val hasProAccess = proStatusManager.currentUserHasProAccess()
val charsLeft = proStatusManager.getCharacterLimit(hasProAccess) - composedCodePointCount

return if(charsLeft < 0){
// the user is trying to send a message that is too long - we should display a dialog
if(currentUser.isPro){
// The LIMIT is an access question; which dialog explains it is a DISPLAY one, and the two
// deliberately read different values.
//
// A user whose plan reads Active but who holds no usable proof is over the standard limit —
// that is ACCESS, correctly refusing. But offering them "upgrade to Pro" is inviting them to
// buy something they are already paying for. They get "message too long" instead, and the
// upsell is reserved for users whose plan says they are not subscribed.
if(proStatusManager.proDataState.value.type is ProStatus.Active){
showMessageTooLongSendDialog()
} else {
showSessionProCTA()
Expand All @@ -69,7 +126,10 @@ abstract class InputbarViewModel(
}

fun onCharLimitTapped(){
if(currentUser.isPro){
// Same split as [validateMessageLength]: this chooses which explanation to show, so it is DISPLAY.
// `handleCharLimitTappedForRegularUser` is the upsell, and a subscriber with no usable proof must
// not be upsold their own plan.
if(proStatusManager.proDataState.value.type is ProStatus.Active){
handleCharLimitTappedForProUser()
} else {
handleCharLimitTappedForRegularUser()
Expand Down Expand Up @@ -103,7 +163,7 @@ abstract class InputbarViewModel(
message = context.resources.getQuantityString(
R.plurals.modalMessageCharacterDisplayDescription,
charsLeft, // quantity for plural
proStatusManager.getCharacterLimit(currentUser.isPro), // 1st arg: total character limit
proStatusManager.getCharacterLimit(isSelfPro.value), // 1st arg: total character limit
charsLeft, // 2nd arg: chars left
),
positiveStyleDanger = false,
Expand All @@ -121,7 +181,7 @@ abstract class InputbarViewModel(
showSimpleDialog = SimpleDialogData(
title = context.getString(R.string.modalMessageTooLongTitle),
message = Phrase.from(context.getString(R.string.modalMessageCharacterTooLongDescription))
.put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUser.isPro))
.put(LIMIT_KEY, proStatusManager.getCharacterLimit(isSelfPro.value))
.format(),
positiveStyleDanger = false,
positiveText = context.getString(R.string.okay),
Expand All @@ -137,7 +197,7 @@ abstract class InputbarViewModel(
showSimpleDialog = SimpleDialogData(
title = context.getString(R.string.modalMessageTooLongTitle),
message = Phrase.from(context.getString(R.string.modalMessageTooLongDescription))
.put(LIMIT_KEY, proStatusManager.getCharacterLimit(currentUser.isPro))
.put(LIMIT_KEY, proStatusManager.getCharacterLimit(isSelfPro.value))
.format(),
positiveStyleDanger = false,
positiveText = context.getString(R.string.okay),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ class MessageDetailsViewModel @AssistedInject constructor(
thread = conversation,
readOnly = isDeprecatedLegacyGroup,
proFeatures = proStatusManager.getMessageProFeatures(messageRecord),
proBadgeClickable = !recipientRepository.getSelf().isPro // no badge click if the current user is pro
// A badge clickable only for non-subscribers is an upsell affordance, so it reads
// DISPLAY: the gate reads ACCESS, the thing that explains the gate reads DISPLAY.
// Was an inverted ACCESS read, which offered a subscriber holding no usable proof a
// route to buy the plan they already have.
proBadgeClickable = proStatusManager.proDataState.value.type !is ProStatus.Active
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -737,9 +737,16 @@ class ConversationSettingsViewModel @AssistedInject constructor(
private fun pinConversation(){
// check the pin limit before continuing
val totalPins = storage.getTotalPinned()
// ENFORCEMENT: the ACCESS function, called at the moment of the decision. The pin limit is
// implemented in TWO ViewModels (see HomeViewModel.setPinned) and both must go through the one
// ACCESS function — a second route to "am I Pro" is how one of them ends up honouring a
// revocation while the other does not.
val maxPins =
proStatusManager.getPinnedConversationLimit(recipientRepository.getSelf().isPro)
proStatusManager.getPinnedConversationLimit(proStatusManager.currentUserHasProAccess())
if (totalPins >= maxPins) {
// RULED: an Active plan gets no CTA — see HomeViewModel.setPinned for the reasoning. The
// pin limit lives in two ViewModels and both must apply it.
if (proStatusManager.proDataState.value.type is ProStatus.Active) return
// the user has reached the pin limit, show the CTA
_dialogState.update {
it.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,15 +502,18 @@ class RecipientRepository @Inject constructor(
it.isExpired(now) || proDatabase.isRevoked(it.revocationTag, snodeClock.get().currentTime())
}

// 2. Determine base Pro Data from valid proofs or ProStatusManager
// 2. Determine base Pro Data from valid proofs
//
// ACCESS ("what may this device do") comes from the PROOF, for ourselves exactly as for anyone
// else — so it runs through the expiry and revocation filter above on every resolve. There is
// deliberately no `isSelf` short-circuit on the cached `get_pro_status` response here: that
// response is DISPLAY ("what state is the plan in"), it is not revocation-filtered, and trusting
// it for access let a cached `Active` outlive a revocation we had already been told about.
//
// The two are MEANT to disagree — a proof that outlives an expired status still grants the
// feature, which is the deliberate overhang. Read `ProStatusManager.proDataState` when you want
// to describe the plan; read this when you want to know what is permitted.
var proData = when {
// For ourselves, we "trust" ProStatusManager more than the ProProofs
recipient.isSelf && proStatusManager.get().proDataState.value.type is ProStatus.Active -> {
RecipientData.ProData(
showProBadge = proStatusManager.get().proDataState.value.showProBadge
)
}

!proDataList.isNullOrEmpty() -> {
RecipientData.ProData(showProBadge = proDataList.any { it.showProBadge })
}
Expand All @@ -520,7 +523,16 @@ class RecipientRepository @Inject constructor(
}

// 3. Apply Debug Overrides
if (recipient.isSelf && proData == null && prefs.forceCurrentUserAsPro()) {
//
// The mocked-proof override is tri-state and BOTH directions are applied here, not just the
// grant: rendering and enforcement must never disagree about what a mocked run is entitled to,
// and `ProStatusManager.currentUserHasProAccess` honours `none` as a denial even against a real
// proof. If this only honoured the grant, a `proProof=none` fixture would show a Pro badge while
// the composer offered the standard limit.
val proAccessOverride = if (recipient.isSelf) prefs.getDebugProAccessOverride() else null
if (recipient.isSelf && proAccessOverride == false) {
proData = null
} else if (recipient.isSelf && proData == null && (proAccessOverride == true || prefs.forceCurrentUserAsPro())) {
proData = RecipientData.ProData(showProBadge = true)
} else if (!recipient.isSelf
&& (recipient.address is Address.Standard)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class DebugMenuViewModel @AssistedInject constructor(
DebugProPlanStatus.NORMAL,
DebugProPlanStatus.LOADING,
DebugProPlanStatus.ERROR,
DebugProPlanStatus.SUCCESS,
),
selectedDebugProPlanStatus = textSecurePreferences.getDebugProPlanStatus() ?: DebugProPlanStatus.NORMAL,
debugProPlans = subscriptionManagers.asSequence()
Expand Down Expand Up @@ -685,9 +686,22 @@ class DebugMenuViewModel @AssistedInject constructor(
}

enum class DebugProPlanStatus(val label: String){
/** No override — the real load state decides. Not the same as [SUCCESS]. */
NORMAL("Normal State"),
LOADING("Always Loading"),
ERROR("Always Erroring out"),

/**
* Forces the refresh state to Success, which asserts that THIS PROCESS has had a fetch confirmed
* by the backend when it has not. That is a lie the app tells itself, so it is only reachable
* from the debug menu or `QaLaunchConfig` and cannot be selected in a shipping build.
*
* Distinct from [NORMAL] on purpose: NORMAL removes the override and defers to the real state,
* which on a cold start is `Init` and therefore Loading. Anything gating on a confirmed fetch —
* `HomeViewModel`'s expiring and expired CTAs — is *defeated* by this value, so do not use it in
* a test whose subject is one of those gates; it would pass whether the gate works or not.
*/
SUCCESS("Always Succeeding"),
}

sealed class Commands {
Expand Down
Loading
Loading