Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -35,6 +35,8 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_HA
import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SEEN_DONATION_CTA_AMOUNT
import org.session.libsession.utilities.TextSecurePreferences.Companion.DEBUG_SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW
import org.session.libsession.utilities.TextSecurePreferences.Companion.DEVNET_SEED_URL
import org.session.libsession.utilities.TextSecurePreferences.Companion.PRO_BACKEND_PUBKEY
import org.session.libsession.utilities.TextSecurePreferences.Companion.PRO_BACKEND_URL
import org.session.libsession.utilities.TextSecurePreferences.Companion.SNODE_POOL_SEED_MARKER
import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONMENT
import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS
Expand Down Expand Up @@ -190,6 +192,17 @@ interface TextSecurePreferences {
fun getDevnetSeedUrl(): String?
fun setDevnetSeedUrl(value: String?)

/**
* Overrides the Session Pro backend, so a QA backend can be targeted without rebuilding. Both
* must be set together: the pubkey is what proofs are verified against, so a QA-signed proof read
* with the production key is simply invalid. `null` (the default) means use the compiled-in
* backend from libsession.
*/
fun getProBackendUrl(): String?
fun setProBackendUrl(value: String?)
fun getProBackendPubkey(): String?
fun setProBackendPubkey(value: String?)

/**
* Identifies the seed configuration the cached snode pool was fetched from, so a pool belonging
* to a previous network can be discarded (see SnodeDirectory). Opaque; do not parse.
Expand Down Expand Up @@ -314,6 +327,8 @@ interface TextSecurePreferences {
const val LAST_VERSION_CHECK = "pref_last_version_check"
const val ENVIRONMENT = "debug_environment"
const val DEVNET_SEED_URL = "debug_devnet_seed_url"
const val PRO_BACKEND_URL = "debug_pro_backend_url"
const val PRO_BACKEND_PUBKEY = "debug_pro_backend_pubkey"
const val SNODE_POOL_SEED_MARKER = "snode_pool_seed_marker"
const val MIGRATED_TO_GROUP_V2_CONFIG = "migrated_to_group_v2_config"
const val MIGRATED_TO_DISABLING_KDF = "migrated_to_disabling_kdf"
Expand Down Expand Up @@ -949,6 +964,18 @@ class AppTextSecurePreferences @Inject constructor(
setStringPreference(DEVNET_SEED_URL, value)
}

override fun getProBackendUrl(): String? = getStringPreference(PRO_BACKEND_URL, null)

override fun setProBackendUrl(value: String?) {
setStringPreference(PRO_BACKEND_URL, value)
}

override fun getProBackendPubkey(): String? = getStringPreference(PRO_BACKEND_PUBKEY, null)

override fun setProBackendPubkey(value: String?) {
setStringPreference(PRO_BACKEND_PUBKEY, value)
}

override fun getSnodePoolSeedMarker(): String? = getStringPreference(SNODE_POOL_SEED_MARKER, null)

override fun setSnodePoolSeedMarker(value: String?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,20 @@ class DebugMenuViewModel @AssistedInject constructor(
STOPPED,
}

/**
* The `label` is what the debug menu shows for selection, so the day counts in it MUST match the
* offsets the fixtures actually use in `ProStatusManager`'s debug branch. Two of these were out of
* step (they said 14 days where the code did 2), which cost a wrong expected string in an Appium
* spec — the label was read as if it were the source of truth. If you change a fixture offset,
* change its label in the same commit.
*/
enum class DebugSubscriptionStatus(val label: String) {
AUTO_GOOGLE("Auto Renewing (Google, 3 months)"),
AUTO_APPLE_REFUNDING("Refunding (Apple, 3 months)"),
EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 14 days, Google, 12 months)"),
EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 40 days, Google, 12 months)"),
EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 2 days, Google, 12 months)"),
EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 30 days, Google, 12 months)"),
AUTO_APPLE("Auto Renewing (Apple, 1 months)"),
EXPIRING_APPLE("Expiring/Cancelled (Expires in 14 days, Apple, 1 months)"),
EXPIRING_APPLE("Expiring/Cancelled (Expires in 2 days, Apple, 1 months)"),
EXPIRED("Expired (Expired 2 days ago, Google)"),
EXPIRED_EARLIER("Expired (Expired 60 days ago, Google)"),
EXPIRED_APPLE("Expired (Expired 2 days ago, Apple)"),
Expand Down
24 changes: 19 additions & 5 deletions app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.view.WindowInsetsCompat
Expand Down Expand Up @@ -265,14 +268,25 @@ class HomeActivity : ScreenLockActionBarActivity(),

val pathStatus by pathManager.status.collectAsState()

// Carried on the Compose node rather than as an `android:contentDescription` on the hosting
// ComposeView, which is where it used to live. The host's attribute was unreliable: Compose
// publishes its own semantics tree for the content (the `clickable` below already gives this
// node a button role), so whether the host's description surfaced in the accessibility tree
// depended on composition timing — which showed up as an intermittent "element not found" in
// the Appium onboarding flow. On the tapped node it is deterministic, and it describes the
// thing that is actually actionable.
val openSettingsDescription = stringResource(R.string.AccessibilityId_profilePicture)

Avatar(
size = LocalDimensions.current.iconMediumAvatar,
data = avatarUtils.getUIDataFromRecipient(recipient),
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = ::openSettings
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = ::openSettings
)
.semantics { contentDescription = openSettingsDescription },
badge = AvatarBadge.ComposeBadge(
content = {
val glowSize = LocalDimensions.current.xxxsSpacing
Expand Down
50 changes: 48 additions & 2 deletions app/src/main/java/org/thoughtcrime/securesms/pro/ProModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,65 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import network.loki.messenger.BuildConfig
import network.loki.messenger.libsession_util.pro.BackendRequests
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log

@Module
@InstallIn(SingletonComponent::class)
class ProModule {
@Provides
fun provideProBackendConfig(): ProBackendConfig {
fun provideProBackendConfig(prefs: TextSecurePreferences): ProBackendConfig {
// The backend URL + Ed25519 signing pubkey come from libsession (single source of truth), so a
// future change happens in exactly one place rather than a per-client copy. x25519 is derived
// on the fly from the Ed key (see ProBackendConfig).
return ProBackendConfig(
val compiledIn = ProBackendConfig(
url = BackendRequests.proBackendUrl(),
ed25519PubKeyHex = BackendRequests.proBackendPubKeyHex(),
)

return qaBackendOverride(prefs) ?: compiledIn
}

/**
* A QA backend supplied as a launch extra (see `QaLaunchConfig`), or `null` for none.
*
* Gated on the same compile-time flag as the reader that writes the preference, so a release build
* cannot be repointed even if the preference were somehow populated. The launcher is an exported
* activity-alias, so this stays defence-in-depth rather than trusting the write path alone.
*
* Re-validated here rather than trusted from the preference: this builds the config used for every
* Pro request, and `ProBackendConfig` throws on a malformed URL or a bad-length key. Falling back
* to the compiled-in backend is the safe failure, so a bad value degrades rather than taking the
* app down during dependency-graph construction.
*/
private fun qaBackendOverride(prefs: TextSecurePreferences): ProBackendConfig? {
if (!BuildConfig.ALLOW_QA_LAUNCH_CONFIG) {
return null
}

val url = prefs.getProBackendUrl()?.takeIf { it.isNotBlank() } ?: return null
val pubkey = prefs.getProBackendPubkey()?.takeIf { it.isNotBlank() } ?: return null

val parsed = url.toHttpUrlOrNull()
if (parsed == null) {
Log.e(TAG, "Ignoring malformed Pro backend override URL: '$url'")
return null
}

return try {
ProBackendConfig(url = parsed, ed25519PubKeyHex = pubkey).also {
Log.i(TAG, "Using Pro backend override: $parsed")
}
} catch (e: RuntimeException) {
Log.e(TAG, "Ignoring unusable Pro backend override", e)
null
}
}

private companion object {
private const val TAG = "ProModule"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ class ProProofGenerationWorker @AssistedInject constructor(
return Result.success()
}

// Pace acquisition. Without a floor this path is a closed loop: a successful generate
// force-refreshes get_pro_status (below), the fetch asks libsession for a renewal target, and
// `target = proofExpiry - PRO_RENEWAL_LEAD` is permanently in the past whenever a proof lives
// for less than the 60-minute lead, so it reschedules us immediately, forever.
//
// Mirrors iOS `SessionProManager.reconcileProofRenewal` and Desktop `ducks/proBackendData.ts`,
// constants included. Note it RE-ARMS rather than skipping: `target <= now` is the normal
// "renewal due" signal, so dropping the work would break real renewals.
val now = snodeClock.currentTime()
val covered = configFactory.withUserConfigs { configs ->
configs.userProfile.getProConfig()?.proProof
}?.let { it.expirySeconds > now.epochSecond } == true

if (covered) darkAttempt = 0
val intervalSeconds = if (covered) {
COVERED_INTERVAL_SECONDS
} else {
(DARK_STEP_SECONDS * darkAttempt).coerceAtMost(DARK_CAP_SECONDS)
}

val sinceLast = now.epochSecond - lastProofRequestAt
if (sinceLast < intervalSeconds) {
val waitSeconds = intervalSeconds - sinceLast
Log.d(
WORK_NAME,
"Last proof request was ${sinceLast}s ago (interval ${intervalSeconds}s, " +
"covered=$covered); re-arming in ${waitSeconds}s"
)
schedule(applicationContext, Duration.ofSeconds(waitSeconds))
return Result.success()
}

// Count the attempt before making it, so one that fails still advances the backoff.
lastProofRequestAt = now.epochSecond
if (!covered) darkAttempt++

return try {
// 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
Expand Down Expand Up @@ -171,6 +207,25 @@ class ProProofGenerationWorker @AssistedInject constructor(
companion object {
private const val WORK_NAME = "ProProofGenerationWorker"

/**
* Minimum spacing between proof requests. **Shared cross-client contract** — iOS
* (`SessionProManager.reconcileProofRenewal`) and Desktop use exactly these values; keep them
* in step, and say why in the commit if they ever have to diverge.
*/
private const val COVERED_INTERVAL_SECONDS = 60L // holding a valid proof: brisk
private const val DARK_STEP_SECONDS = 15L // no valid proof: 15s * attempt …
private const val DARK_CAP_SECONDS = 900L // … capped at 15 minutes

/**
* Pacing state, deliberately in-memory to match iOS and Desktop, which both hold it as an
* ordinary field. A process restart resets it, costing at most one extra request per launch
* — the loop this guards against was a tight re-schedule cycle within a single process.
*/
// 0 rather than a sentinel minimum: `now - lastProofRequestAt` would overflow from Long.MIN_VALUE
// and come out negative, throttling the very first request instead of letting it through.
@Volatile private var lastProofRequestAt = 0L
@Volatile private var darkAttempt = 0

suspend fun schedule(context: Context, delay: Duration? = null) {
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ class ProStatusManager @Inject constructor(
inGracePeriod = false
)

// 2 days is deliberate and load-bearing: it is INSIDE the 7-day window that
// gates the expiring CTA (`HomeViewModel`, `validUntil.isBefore(now.plus(7, DAYS))`),
// which is what makes this the fixture you pick to eyeball that CTA. The
// `_LATER` variant below is the deliberate opposite. Moving this outside 7 days
// would make the two behaviourally identical and leave no way to trigger the CTA
// by hand.
DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> ProStatus.Active.Expiring(
renewingAt = Instant.now() + Duration.ofDays(2),
duration = ProSubscriptionDuration.TWELVE_MONTHS.period,
Expand All @@ -180,7 +186,7 @@ class ProStatusManager @Inject constructor(
)

DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> ProStatus.Active.Expiring(
renewingAt = Instant.now() + Duration.ofDays(40),
renewingAt = Instant.now() + Duration.ofDays(EXPIRING_LATER_DAYS),
duration = ProSubscriptionDuration.TWELVE_MONTHS.period,
providerData = providerMetadata(PAYMENT_PROVIDER_GOOGLE_PLAY, application),
quickRefundExpiry = Instant.now() + Duration.ofDays(7),
Expand Down Expand Up @@ -503,10 +509,37 @@ class ProStatusManager @Inject constructor(
private const val PURCHASE_POLL_MAX_MS = 150_000L

// 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
//
// Lazy, and it has to stay that way: SessionProtocol is a LibSessionUtilCApi object, so merely
// reading one of its constants runs System.loadLibrary("session_util"). Doing that from this
// companion's initialiser meant ProStatusManager could not be class-initialised anywhere the
// native library is absent — which is every JVM unit test — so Mockito could not instrument it
// and every test constructing a ConversationViewModel failed with NoClassDefFoundError.
// Deferring to first read keeps the constants single-sourced without dragging the native
// library into class initialisation.
val MAX_CHARACTER_PRO by lazy { SessionProtocol.PRO_HIGHER_CHARACTER_LIMIT } // max message codepoints for pro users
private val MAX_CHARACTER_REGULAR by lazy { 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"

/**
* Remaining access for the `EXPIRING_GOOGLE_LATER` debug fixture, in days. **A test pins this
* value** — don't change it casually.
*
* Two non-obvious properties keep that safe, both worth preserving:
*
* The label reads "30 days" **only because `DateUtils.getExpiryString` rounds up.** The fixture
* sets `renewingAt = now + 30d` when `proDataState` recomputes, but the label is rendered from a
* *later* `now`, so the true remaining is always slightly under 30. That ceiling is load-bearing:
* make it floor for unrelated reasons and the label silently becomes "29 days".
*
* The `Instant.now()` here is understood, not an oversight — the rest of the Pro stack uses
* `SnodeClock`. It is safe because **both** sides of `Duration.between(now, renewingAt)` read the
* same device clock, so skew cancels and the ceiling absorbs the remainder. Don't tidy it into
* `SnodeClock` assuming it is a latent bug; it is safe by that cancellation, not by the clock
* being right.
*/
private const val EXPIRING_LATER_DAYS = 30L
}
}
Loading
Loading