Skip to content
Merged
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 @@ -69,6 +71,7 @@ import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel
import org.thoughtcrime.securesms.pro.toProMessageFeatures
import org.thoughtcrime.securesms.pro.toProProfileFeatures
import java.io.IOException
import java.time.Instant
import java.time.ZonedDateTime
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -190,6 +193,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 All @@ -206,6 +220,8 @@ interface TextSecurePreferences {

fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus?
fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?)
fun getDebugProAccessExpiry(): Instant?
fun setDebugProAccessExpiry(expiry: Instant?)
fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus?
fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?)
fun getDebugForceNoBilling(): Boolean
Expand Down Expand Up @@ -314,6 +330,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 @@ -368,6 +386,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_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"
const val DEBUG_WITHIN_QUICK_REFUND = "debug_within_quick_refund"
Expand Down Expand Up @@ -949,6 +968,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 Expand Up @@ -1216,6 +1247,20 @@ class AppTextSecurePreferences @Inject constructor(
_events.tryEmit(TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS)
}

override fun getDebugProAccessExpiry(): Instant? {
return getStringPreference(TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY, null)
?.toLongOrNull()
?.let(Instant::ofEpochMilli)
}

override fun setDebugProAccessExpiry(expiry: Instant?) {
setStringPreference(
TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY,
expiry?.toEpochMilli()?.toString()
)
_events.tryEmit(TextSecurePreferences.DEBUG_PRO_ACCESS_EXPIRY)
}

override fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? {
return getStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, null)?.let {
DebugMenuViewModel.DebugProPlanStatus.valueOf(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,16 +659,29 @@ 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.
*
* That warning was then proved on the very next pair: `EXPIRED`/`EXPIRED_APPLE` claimed 2 days
* while the code did 14, because the fix above only covered the `EXPIRING` labels. **Correcting
* the labels that lie is not the same as checking the ones that didn't**, so when this drifts
* again, re-read every offset rather than the ones a report names — the labels are now the
* documented contract an Appium spec is written against.
*/
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)"),
EXPIRED("Expired (Expired 2 days ago, Google)"),
EXPIRING_APPLE("Expiring/Cancelled (Expires in 2 days, Apple, 1 months)"),
EXPIRED("Expired (Expired 14 days ago, Google)"),
EXPIRED_EARLIER("Expired (Expired 60 days ago, Google)"),
EXPIRED_APPLE("Expired (Expired 2 days ago, Apple)"),
EXPIRED_APPLE("Expired (Expired 14 days ago, Apple)"),
}

enum class DebugProPlanStatus(val label: String){
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
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ fun ProSettingsHome(
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing)
) {
Text(
// One id for the slot, shared with the error state below: the four
// possible messages are told apart by their text, not by separate ids, so
// it belongs on the node that carries the message rather than the Row.
modifier = Modifier.qaTag(R.string.qa_pro_settings_status_banner),
text = Phrase.from(context.getText(
when(subscriptionType){
is ProStatus.Active -> R.string.proStatusLoadingSubtitle
Expand All @@ -178,6 +182,8 @@ fun ProSettingsHome(
horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)
) {
Text(
// Same id as the loading state above — deliberately. See there.
modifier = Modifier.qaTag(R.string.qa_pro_settings_status_banner),
text = Phrase.from(context.getText(
when(subscriptionType){
is ProStatus.Active -> R.string.proErrorRefreshingStatus
Expand Down Expand Up @@ -318,6 +324,7 @@ fun ProStats(
dropShadow = LocalColors.current.isLight,
title = Phrase.from(LocalContext.current, R.string.proStats)
.format().toString(),
titleQaTag = R.string.qa_pro_settings_stats_header,
titleIcon = {
val tooltipState = rememberTooltipState(isPersistent = true)
val scope = rememberCoroutineScope()
Expand Down Expand Up @@ -519,6 +526,7 @@ fun ProSettings(
modifier = modifier,
title = Phrase.from(LocalContext.current, R.string.proSettings)
.format().toString(),
titleQaTag = R.string.qa_pro_settings_manage_header,
) {
val refunding = proStatus.refundInProgress

Expand Down Expand Up @@ -590,6 +598,10 @@ fun ProSettings(
}
},
qaTag = R.string.qa_pro_settings_action_update_plan,
// The remaining-access line. Uniquely identified rather than left as the shared
// `action-item-subtitle`, which is on every row here and so can only be addressed by
// traversing from this row — a traversal that breaks whenever the layout is restructured.
subtitleQaTag = R.string.qa_pro_settings_update_plan_subtitle,
onClick = { sendCommand(GoToChoosePlan(inSheet)) }
)
Divider()
Expand Down Expand Up @@ -623,6 +635,7 @@ fun ProFeatures(
modifier = modifier,
title = Phrase.from(LocalContext.current, R.string.proBetaFeatures)
.format().toString(),
titleQaTag = R.string.qa_pro_settings_features_header,
) {
// Cell content
Column(
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
Loading
Loading