diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 958cfbc39f..dfcd0f81fc 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -133,6 +133,15 @@ class MessageSender @Inject constructor( } } + // Pre-launch no proof is ever attached, so a rotating-key signature would vouch for nothing + private fun proRotatingEd25519PrivKey(): ByteArray? { + if (!proStatusManager.get().isPostPro()) return null + + return configFactory.withUserConfigs { configs -> + configs.userProfile.getProConfig() + }?.rotatingPrivateKey?.data + } + private fun buildProto(msg: Message): SessionProtos.Content { try { val builder = SessionProtos.Content.newBuilder() @@ -207,9 +216,7 @@ class MessageSender @Inject constructor( throw Error.InvalidMessage() } - val proRotatingEd25519PrivKey = configFactory.withUserConfigs { configs -> - configs.userProfile.getProConfig() - }?.rotatingPrivateKey?.data + val proRotatingEd25519PrivKey = proRotatingEd25519PrivKey() val messagePlaintext = buildProto(message).toByteArray() @@ -391,9 +398,7 @@ class MessageSender @Inject constructor( } val plaintext = SessionProtocol.encodeForCommunity( plaintext = content.toByteArray(), - proRotatingEd25519PrivKey = configFactory.withUserConfigs { configs -> - configs.userProfile.getProConfig() - }?.rotatingPrivateKey?.data, + proRotatingEd25519PrivKey = proRotatingEd25519PrivKey(), ) val openGroupMessage = OpenGroupMessage( diff --git a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index 2bd02c519c..06d1b7437f 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -62,6 +62,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.SEND_WIT import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_INCOMING_MESSAGE_PRO import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO +import org.session.libsession.utilities.TextSecurePreferences.Companion.SET_FORCE_POST_PRO import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_NOTIFICATION import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CALL_WARNING import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOW_DONATION_CTA_FROM_POSITIVE_REVIEW @@ -158,11 +159,14 @@ interface TextSecurePreferences { fun setForceOtherUsersAsPro(isPro: Boolean) fun forceIncomingMessagesAsPro(): Boolean fun setForceIncomingMessagesAsPro(isPro: Boolean) + fun forcePostPro(): Boolean + fun setForcePostPro(postPro: Boolean) fun hasSeenProExpiring(): Boolean fun setHasSeenProExpiring() fun hasSeenProExpired(): Boolean fun setHasSeenProExpired() fun clearProExpiryView() + fun watchPostProStatus(): StateFlow fun hasSeenSlowModeCallWarning(): Boolean fun setHasSeenSlowModeCallWarning(value: Boolean) fun setShownCallWarning(): Boolean @@ -394,6 +398,7 @@ interface TextSecurePreferences { const val SET_FORCE_PRO_REVOCATION_REFRESH = "pref_force_pro_revocation_refresh" const val SET_FORCE_OTHER_USERS_PRO = "pref_force_other_users_pro" const val SET_FORCE_INCOMING_MESSAGE_PRO = "pref_force_incoming_message_pro" + const val SET_FORCE_POST_PRO = "pref_force_post_pro" const val HAS_SEEN_PRO_EXPIRING = "has_seen_pro_expiring" const val HAS_SEEN_PRO_EXPIRED = "has_seen_pro_expired" const val SHOWN_SLOW_MODE_CALL_WARNING = "has_seen_slow_mode_call_warning" @@ -669,6 +674,9 @@ class AppTextSecurePreferences @Inject constructor( @param:ApplicationContext private val context: Context, private val json: Json, ): TextSecurePreferences { + // The master gate for Session Pro. Off, this account can neither use nor buy Pro, while other people's Pro + // (badges, longer messages, animated avatars) is still honoured. + private val postProLaunchState = MutableStateFlow(getBooleanPreference(SET_FORCE_POST_PRO, false)) private val hiddenPasswordState = MutableStateFlow(getBooleanPreference(HIDE_PASSWORD, false)) override var migratedToGroupV2Config: Boolean @@ -1127,6 +1135,16 @@ class AppTextSecurePreferences @Inject constructor( setBooleanPreference(SET_FORCE_INCOMING_MESSAGE_PRO, isPro) } + override fun forcePostPro(): Boolean { + return postProLaunchState.value + } + + override fun setForcePostPro(postPro: Boolean) { + setBooleanPreference(SET_FORCE_POST_PRO, postPro) + postProLaunchState.update { postPro } + _events.tryEmit(SET_FORCE_POST_PRO) + } + override fun hasSeenProExpiring(): Boolean { return getBooleanPreference(HAS_SEEN_PRO_EXPIRING, false) } @@ -1148,6 +1166,10 @@ class AppTextSecurePreferences @Inject constructor( setBooleanPreference(HAS_SEEN_PRO_EXPIRING, false) } + override fun watchPostProStatus(): StateFlow { + return postProLaunchState + } + override fun getFingerprintKeyGenerated(): Boolean { return getBooleanPreference(TextSecurePreferences.FINGERPRINT_KEY_GENERATED, false) } @@ -1251,6 +1273,7 @@ class AppTextSecurePreferences @Inject constructor( * Clear all prefs and reset our observables */ override fun clearAll() { + postProLaunchState.update { false } hiddenPasswordState.update { false } getDefaultSharedPreferences(context).edit(commit = true) { clear() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index c1a8379d30..75e7d7d900 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -83,7 +83,8 @@ abstract class InputbarViewModel( // 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 + showProBadge = proStatusManager.isPostPro() && + proStatusManager.proDataState.value.type !is ProStatus.Active ) } else { null @@ -113,7 +114,7 @@ abstract class InputbarViewModel( // 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){ + if(!proStatusManager.isPostPro() || proStatusManager.proDataState.value.type is ProStatus.Active){ showMessageTooLongSendDialog() } else { showSessionProCTA() @@ -129,7 +130,7 @@ abstract class InputbarViewModel( // 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){ + if(!proStatusManager.isPostPro() || proStatusManager.proDataState.value.type is ProStatus.Active){ handleCharLimitTappedForProUser() } else { handleCharLimitTappedForRegularUser() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt index 55c6c89fec..32128c4f47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -483,6 +483,8 @@ class RecipientRepository @Inject constructor( } } + changeSources?.add(proStatusManager.get().postProLaunchStatus.drop(1)) + return updatedValue to changeSources?.let { merge(*it.toTypedArray()) } } @@ -701,7 +703,8 @@ class RecipientRepository @Inject constructor( revocationTag = "a1b2c3d4", ) ) - } else if (pro != null) { + } else if (pro != null && proStatusManager.get().isPostPro()) { + // Pre-launch our own proof, even one synced from another device, is no entitlement here proDataContext?.addProData( RecipientSettings.ProData( showProBadge = configs.userProfile.getProFeatures().contains( diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt index dec8ceb39b..35f82ca921 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -383,6 +383,15 @@ fun DebugMenu( } + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Set app as post Pro launch", + checked = uiState.forcePostPro, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForcePostPro(it)) + } + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) DebugSwitchRow( text = "Set other users as Pro", @@ -944,6 +953,7 @@ fun PreviewDebugMenu() { forceCurrentUserAsPro = true, forceIncomingMessagesAsPro = true, forceOtherUsersAsPro = false, + forcePostPro = false, forceShortTTl = false, messageProFeature = setOf(ProMessageFeature.HIGHER_CHARACTER_LIMIT), dbInspectorState = DebugMenuViewModel.DatabaseInspectorState.STARTED, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt index e8c85fa12f..dedf4141f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -105,6 +105,7 @@ class DebugMenuViewModel @AssistedInject constructor( forceCurrentUserAsPro = textSecurePreferences.forceCurrentUserAsPro(), forceOtherUsersAsPro = textSecurePreferences.forceOtherUsersAsPro(), forceIncomingMessagesAsPro = textSecurePreferences.forceIncomingMessagesAsPro(), + forcePostPro = textSecurePreferences.forcePostPro(), forceShortTTl = textSecurePreferences.forcedShortTTL(), debugAvatarReupload = textSecurePreferences.debugAvatarReupload, messageProFeature = textSecurePreferences.getDebugMessageFeatures(), @@ -347,6 +348,13 @@ class DebugMenuViewModel @AssistedInject constructor( } } + is Commands.ForcePostPro -> { + textSecurePreferences.setForcePostPro(command.set) + _uiState.update { + it.copy(forcePostPro = command.set) + } + } + is Commands.ForceShortTTl -> { textSecurePreferences.setForcedShortTTL(command.set) _uiState.update { @@ -616,6 +624,7 @@ class DebugMenuViewModel @AssistedInject constructor( val forceOtherUsersAsPro: Boolean, val forceIncomingMessagesAsPro: Boolean, val messageProFeature: Set, + val forcePostPro: Boolean, val forceShortTTl: Boolean, val forceDeprecationState: LegacyGroupDeprecationManager.DeprecationState?, val debugAvatarReupload: Boolean, @@ -711,6 +720,7 @@ class DebugMenuViewModel @AssistedInject constructor( data class ForceIncomingMessagesAsPro(val set: Boolean) : Commands() data class ForceNoBilling(val set: Boolean) : Commands() data class WithinQuickRefund(val set: Boolean) : Commands() + data class ForcePostPro(val set: Boolean) : Commands() data class ForceShortTTl(val set: Boolean) : Commands() data class SetMessageProFeature(val feature: ProFeature, val set: Boolean) : Commands() data class ShowDeprecationChangeDialog(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt index 3f7e4c127f..6ab4df83b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -314,6 +314,7 @@ fun Settings( Buttons( recoveryHidden = uiState.recoveryHidden, pathStatus = uiState.pathStatus, + postPro = uiState.isPostPro, proDataState = uiState.proDataState, sendCommand = sendCommand ) @@ -397,6 +398,7 @@ fun Settings( AvatarDialog( state = uiState.avatarDialogState, isPro = uiState.proDataState.type is ProStatus.Active, + isPostPro = uiState.isPostPro, sendCommand = sendCommand, startAvatarSelection = startAvatarSelection ) @@ -535,6 +537,7 @@ fun Settings( fun Buttons( recoveryHidden: Boolean, pathStatus: PathStatus, + postPro: Boolean, proDataState: ProDataState, sendCommand: (SettingsViewModel.Commands) -> Unit, ) { @@ -577,46 +580,48 @@ fun Buttons( Cell { Column { - ItemButton( - text = annotatedStringResource( - when (proDataState.type) { - is ProStatus.Active -> Phrase.from( - LocalContext.current, - R.string.sessionProBeta - ) - .format().toString() - - is ProStatus.NeverSubscribed -> Phrase.from( - LocalContext.current, - R.string.upgradeSession - ) - .format().toString() - - is ProStatus.Expired -> Phrase.from( - LocalContext.current, - R.string.proRenewBeta + if(postPro){ + ItemButton( + text = annotatedStringResource( + when (proDataState.type) { + is ProStatus.Active -> Phrase.from( + LocalContext.current, + R.string.sessionProBeta + ) + .format().toString() + + is ProStatus.NeverSubscribed -> Phrase.from( + LocalContext.current, + R.string.upgradeSession + ) + .format().toString() + + is ProStatus.Expired -> Phrase.from( + LocalContext.current, + R.string.proRenewBeta + ) + .format().toString() + } + ), + icon = { + Image( + modifier = Modifier.size(LocalDimensions.current.iconLargeAvatar) + .align(Alignment.Center), + painter = painterResource(R.drawable.ic_pro_badge), + contentDescription = null, ) - .format().toString() - } - ), - icon = { - Image( - modifier = Modifier.size(LocalDimensions.current.iconLargeAvatar) - .align(Alignment.Center), - painter = painterResource(R.drawable.ic_pro_badge), - contentDescription = null, - ) - }, - modifier = Modifier.qaTag(R.string.qa_settings_item_pro), - // The row id above is the tap target and carries no text; this one is on the - // label, so a test can read WHICH of the three states the row is showing. - textQaTag = R.string.qa_settings_item_pro_title, - colors = accentTextButtonColors() - ) { - activity?.push() - } + }, + modifier = Modifier.qaTag(R.string.qa_settings_item_pro), + // The row id above is the tap target and carries no text; this one is on the + // label, so a test can read WHICH of the three states the row is showing. + textQaTag = R.string.qa_settings_item_pro_title, + colors = accentTextButtonColors() + ) { + activity?.push() + } - Divider() + Divider() + } // Invite a friend ItemButton( @@ -923,6 +928,7 @@ fun AvatarOption( fun AvatarDialog( state: SettingsViewModel.AvatarDialogState, isPro: Boolean, + isPostPro: Boolean, sendCommand: (SettingsViewModel.Commands) -> Unit, startAvatarSelection: () -> Unit, ){ @@ -935,20 +941,22 @@ fun AvatarDialog( // custom content that has the displayed images // animated Pro title - ProBadgeText( - modifier = Modifier - .padding( - top = LocalDimensions.current.xxxsSpacing, - bottom = LocalDimensions.current.xsSpacing, - ) - .clickable { - sendCommand(ShowAnimatedProCTA) - }, - text = stringResource(if(isPro) R.string.proAnimatedDisplayPictureModalDescription - else R.string.proAnimatedDisplayPicturesNonProModalDescription), - textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary), - badgeAtStart = isPro - ) + if(isPostPro){ + ProBadgeText( + modifier = Modifier + .padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xsSpacing, + ) + .clickable { + sendCommand(ShowAnimatedProCTA) + }, + text = stringResource(if(isPro) R.string.proAnimatedDisplayPictureModalDescription + else R.string.proAnimatedDisplayPicturesNonProModalDescription), + textStyle = LocalType.current.base.copy(color = LocalColors.current.textSecondary), + badgeAtStart = isPro + ) + } // main container that control the overall size and adds the rounded bg Box( @@ -1073,6 +1081,7 @@ private fun SettingsScreenPreview() { refreshState = State.Success(Unit), showProBadge = true ), + isPostPro = true, username = "Atreyu", accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", pathStatus = PathStatus.READY, @@ -1097,6 +1106,7 @@ fun PreviewAvatarDialog( AvatarDialog( state = SettingsViewModel.AvatarDialogState.NoAvatar, isPro = false, + isPostPro = false, sendCommand = {}, startAvatarSelection = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index 45c271e65c..aec5129212 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -112,6 +112,7 @@ class SettingsViewModel @Inject constructor( pathStatus = PathStatus.BUILDING, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), + isPostPro = proStatusManager.isPostPro(), proDataState = getDefaultSubscriptionStateData(), )) val uiState: StateFlow @@ -142,6 +143,12 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(avatarDialogState = getDefaultAvatarDialogState()) } } + viewModelScope.launch { + proStatusManager.postProLaunchStatus.collect { postPro -> + _uiState.update { it.copy(isPostPro = postPro) } + } + } + viewModelScope.launch { prefs.watchHidePassword().collect { hidden -> _uiState.update { it.copy(recoveryHidden = hidden) } @@ -281,7 +288,7 @@ class SettingsViewModel @Inject constructor( ?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show() // if the selected avatar is animated but the user isn't pro, show the animated pro CTA - if (tempAvatar.isAnimated && !selfRecipient.value.isPro) { + if (tempAvatar.isAnimated && !selfRecipient.value.isPro && proStatusManager.isPostPro()) { showAnimatedProCTA() return } @@ -375,9 +382,11 @@ class SettingsViewModel @Inject constructor( fun hasNetworkConnection(): Boolean = connectivity.networkAvailable.value - fun isAnimated(uri: Uri): Boolean = AnimatedImageUtils.isAnimated(context, uri) + fun isAnimated(uri: Uri): Boolean = proStatusManager.isPostPro() // block animated avatars prior to pro + && AnimatedImageUtils.isAnimated(context, uri) - fun isAnimated(rawImageData: ByteArray): Boolean = AnimatedImageUtils.isAnimated(rawImageData) + fun isAnimated(rawImageData: ByteArray): Boolean = proStatusManager.isPostPro() // block animated avatars prior to pro + && AnimatedImageUtils.isAnimated(rawImageData) private fun showAnimatedProCTA() { // show the right CTA based on pro state @@ -711,6 +720,7 @@ class SettingsViewModel @Inject constructor( val avatarCTAState: AvatarCTAState = AvatarCTAState.Hidden, val usernameDialog: UsernameDialogData? = null, val showSimpleDialog: SimpleDialogData? = null, + val isPostPro: Boolean, val proDataState: ProDataState, ) 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 6d91a51b6e..5d337bde09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/FetchProStatusWorker.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException @@ -52,8 +53,14 @@ class FetchProStatusWorker @AssistedInject constructor( private val loginStateRepository: LoginStateRepository, private val snodeClock: SnodeClock, private val configFactory: ConfigFactoryProtocol, + private val prefs: TextSecurePreferences, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + if (!prefs.forcePostPro()) { + Log.d(TAG, "Pro status fetch skipped because pro is not enabled") + return Result.success() + } + val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { "User must be logged in to fetch pro status" 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 49cf14f5f3..2edb8750ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProProofGenerationWorker.kt @@ -20,6 +20,7 @@ import network.loki.messenger.libsession_util.pro.ProConfig import network.loki.messenger.libsession_util.pro.ProProof import org.session.libsession.network.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.withMutableUserConfigs import org.session.libsession.utilities.withUserConfigs import org.session.libsignal.exceptions.NonRetryableException @@ -56,8 +57,15 @@ class ProProofGenerationWorker @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val snodeClock: SnodeClock, private val proStatusRepository: Provider, + private val prefs: TextSecurePreferences, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { + // A persisted schedule outlives the gate closing, so this is checked here rather than trusted to the scheduler + if (!prefs.forcePostPro()) { + Log.d(WORK_NAME, "Pro proof generation skipped because pro is not enabled") + return Result.success() + } + val proMasterKey = requireNotNull(loginStateRepository.peekLoginState()?.seeded?.proMasterPrivateKey) { "User must be logged to generate proof" } 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 c4266888db..039028e8f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeoutOrNull @@ -204,7 +206,7 @@ class ProStatusManager @Inject constructor( ProDataState( type = proStatusState.lastUpdated?.let { (response, confirmedAt) -> response.toProStatus(nowMs, application, refundInProgress, confirmedAt) - } ?: seedDisplayStatusFromConfig(), + } ?: if (isPostPro()) seedDisplayStatusFromConfig() else ProStatus.NeverSubscribed, showProBadge = showProBadgePreference, refreshState = proDataRefreshState ) @@ -441,7 +443,33 @@ class ProStatusManager @Inject constructor( else -> this } - override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = supervisorScope { + private val _postProLaunchStatus = MutableStateFlow(isPostPro()) + val postProLaunchStatus: StateFlow = _postProLaunchStatus + + init { + scope.launch { + prefs.watchPostProStatus().collect { + _postProLaunchStatus.update { isPostPro() } + } + } + } + + // Pre-launch only the revocation list still polls, because other people's proofs are checked against it. + // Everything else is about our own Pro and may not run. Those workers are cancelled rather than left to + // lapse because they persist across process restarts, and a proof worker scheduled before the gate closed + // would otherwise still mint one. + override suspend fun doWhileLoggedIn(loggedInState: LoggedInState): Unit = postProLaunchStatus.collectLatest { postLaunch -> + if (!postLaunch) { + RevocationListPollingWorker.schedule(application) + FetchProStatusWorker.cancel(application) + ProProofGenerationWorker.cancel(application) + return@collectLatest + } + + supervisorScope { launchProTasks() } + } + + private fun CoroutineScope.launchProTasks() { launch { // QA only. The pending poll is a WorkManager job carrying a delay rather than a stored // instant, so there is nothing to backdate; cancelling it is the equivalent, because @@ -763,6 +791,9 @@ class ProStatusManager @Inject constructor( * expired status keeps the features, which is the deliberate overhang. */ fun currentUserProProofForAccess(): ProProof? { + // A proof synced from another device is still no entitlement pre-launch + if (!isPostPro()) return null + val proof = configFactory.get() .withUserConfigs { it.userProfile.getProConfig() } ?.proProof @@ -835,7 +866,7 @@ class ProStatusManager @Inject constructor( * Logic to determine if we should animate the avatar for a user or freeze it on the first frame */ fun freezeFrameForUser(recipient: Recipient): Boolean{ - return if(recipient.isCommunityRecipient) false else !recipient.isPro + return if(!isPostPro() || recipient.isCommunityRecipient) false else !recipient.isPro } /** @@ -843,7 +874,8 @@ class ProStatusManager @Inject constructor( */ fun getIncomingMessageMaxLength(message: VisibleMessage): Int { // if the debug is set, return that - if (prefs.forceIncomingMessagesAsPro()) return MAX_CHARACTER_PRO + // Pre-launch nothing is restricted for lacking Pro, so the Pro limit applies to every sender + if (prefs.forceIncomingMessagesAsPro() || !isPostPro()) return MAX_CHARACTER_PRO if (message.proFeatures.contains(ProMessageFeature.HIGHER_CHARACTER_LIMIT)) { return MAX_CHARACTER_PRO @@ -852,11 +884,17 @@ class ProStatusManager @Inject constructor( return MAX_CHARACTER_REGULAR } + fun isPostPro(): Boolean { + return prefs.forcePostPro() + } + fun getCharacterLimit(isPro: Boolean): Int { return if (isPro) MAX_CHARACTER_PRO else MAX_CHARACTER_REGULAR } fun getPinnedConversationLimit(isPro: Boolean): Int { + if(!isPostPro()) return Int.MAX_VALUE // allow infinite pins while not in post Pro + return if (isPro) Int.MAX_VALUE else MAX_PIN_REGULAR } @@ -913,6 +951,8 @@ class ProStatusManager @Inject constructor( * and it auto-clears once the entitlement lands. */ suspend fun onPurchaseInFlight() { + if (!isPostPro()) return + val nowSeconds = snodeClock.currentTime().epochSecond configFactory.get().withMutableUserConfigs { configs -> configs.userProfile.setProPrepaid(nowSeconds) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt index e8d6c35237..c47f02122f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusRepository.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.session.libsession.network.SnodeClock +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.auth.LoginStateRepository import org.thoughtcrime.securesms.debugmenu.DebugLogGroup @@ -31,6 +32,7 @@ class ProStatusRepository @Inject constructor( private val snodeClock: SnodeClock, @param:ManagerScope private val scope: CoroutineScope, loginStateRepository: LoginStateRepository, + private val prefs: TextSecurePreferences, private val networkConnectivity: NetworkConnectivity, ) { sealed interface LoadState { @@ -140,6 +142,11 @@ class ProStatusRepository @Inject constructor( private var fetchedInThisProcess = false fun requestRefresh(immediate: Boolean = false) { + if (!prefs.forcePostPro()) { + Log.d(DebugLogGroup.PRO_DATA.label, "Pro hasn't been enabled, skipping refresh") + return + } + if (immediate) { Log.d(DebugLogGroup.PRO_DATA.label, "Scheduling immediate fetch of Pro status from server") fetchedInThisProcess = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt index 69c240f04f..1d83e04a30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/qa/QaLaunchConfig.kt @@ -148,6 +148,16 @@ object QaLaunchConfig { */ private const val EXTRA_FORCE_PRO_REVOCATION_REFRESH = "sessionForceProRevocationRefresh" + /** + * Turns the Session Pro master gate on or off: `true` | `1` | `false` | `0`. Absent leaves the stored + * setting alone. + * + * It is off by default in every build, so without this a harness could only reach Pro through the + * debug menu. The key matches iOS's `sessionPro` launch environment variable, so one cross-platform + * config drives both. + */ + private const val EXTRA_SESSION_PRO = "sessionPro" + /** * States whether the app should consider itself UPDATED rather than freshly installed, which decides * which events may raise the in-app review prompt: when updated, only the donate trigger can; when @@ -300,6 +310,7 @@ object QaLaunchConfig { applyDevnetSeedUrl(intent, prefs) applyServiceNetwork(intent, prefs) applyProBackend(intent, prefs) + applySessionPro(intent, prefs) applyProBackendStatus(intent, prefs) // After the status extra: it overrides the access half that one sets. applyProProof(intent, prefs) @@ -334,6 +345,7 @@ object QaLaunchConfig { EXTRA_SERVICE_NETWORK, EXTRA_PRO_BACKEND_URL, EXTRA_PRO_BACKEND_PUBKEY, + EXTRA_SESSION_PRO, EXTRA_PRO_BACKEND_STATUS, EXTRA_PRO_PROOF, EXTRA_FORCE_PRO_REVOCATION_REFRESH, @@ -649,6 +661,26 @@ object QaLaunchConfig { * HomeActivity creation rather than once per test, and a fresh install creates it a second time * after onboarding with no QA extras attached. */ + private fun applySessionPro(intent: Intent, prefs: TextSecurePreferences): Boolean { + if (!intent.hasExtra(EXTRA_SESSION_PRO)) { + return false + } + + val raw = intent.getStringExtra(EXTRA_SESSION_PRO).orEmpty().trim() + val enabled = when (raw.lowercase()) { + "true", "1" -> true + "false", "0" -> false + else -> { + Log.e(TAG, "Ignoring unknown '$EXTRA_SESSION_PRO' extra: '$raw'. Use true | false.") + return false + } + } + + prefs.setForcePostPro(enabled) + Log.i(TAG, "Set Session Pro to $enabled (from '$raw')") + return true + } + private fun applyForceProRevocationRefresh(intent: Intent, prefs: TextSecurePreferences): Boolean { if (!intent.hasExtra(EXTRA_FORCE_PRO_REVOCATION_REFRESH)) { return false diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt index a63776362a..ab54623381 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/DefaultConversationRepository.kt @@ -184,7 +184,8 @@ class DefaultConversationRepository @Inject constructor( // If pro status pref changes, the convo is likely needing changes too TextSecurePreferences.Companion.events.filter { it == TextSecurePreferences.Companion.SET_FORCE_OTHER_USERS_PRO || - it == TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO + it == TextSecurePreferences.Companion.SET_FORCE_CURRENT_USER_PRO || + it == TextSecurePreferences.Companion.SET_FORCE_POST_PRO } ).debounce(500) .onStart { emit(allAddresses) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/pro/ProPreLaunchGateTest.kt b/app/src/test/java/org/thoughtcrime/securesms/pro/ProPreLaunchGateTest.kt new file mode 100644 index 0000000000..d453771c8c --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/pro/ProPreLaunchGateTest.kt @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.pro + +import android.app.Application +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.recipients.Recipient +import org.thoughtcrime.securesms.auth.LoginStateRepository + +/** + * Flag off, this account gets no Pro and nothing is restricted for lacking it; flag on, the Pro rules apply. + * + * The character limits are not covered here: they are libsession constants, and this JVM has no native + * library to read them from. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ProPreLaunchGateTest { + + private val configFactory: ConfigFactoryProtocol = mock() + private val configFactoryLazy: dagger.Lazy = mock { + on { get() } doReturn configFactory + } + + private fun manager(postPro: Boolean): ProStatusManager { + val prefs: TextSecurePreferences = mock { + on { forcePostPro() } doReturn postPro + on { watchPostProStatus() } doReturn MutableStateFlow(postPro) + } + val loginState: LoginStateRepository = mock { + on { flowWithLoggedInState(any()) } doReturn emptyFlow() + } + + return ProStatusManager( + application = mock(), + prefs = prefs, + scope = CoroutineScope(UnconfinedTestDispatcher()), + serverApiExecutor = mock(), + backendConfig = mock(), + loginState = loginState, + proDatabase = mock(), + snodeClock = mock(), + proStatusRepository = mock(), + configFactory = configFactoryLazy, + appVisibilityManager = mock(), + ) + } + + private fun nonProContact(): Recipient = mock { + on { isCommunityRecipient } doReturn false + on { isPro } doReturn false + } + + @Test + fun `pre-launch there is no pinned conversation limit`() { + assertEquals(Int.MAX_VALUE, manager(postPro = false).getPinnedConversationLimit(isPro = false)) + } + + @Test + fun `post-launch a non-Pro user is held to the pinned conversation limit`() { + assertEquals( + ProStatusManager.MAX_PIN_REGULAR, + manager(postPro = true).getPinnedConversationLimit(isPro = false) + ) + } + + @Test + fun `pre-launch no avatar is frozen`() { + assertFalse(manager(postPro = false).freezeFrameForUser(nonProContact())) + } + + @Test + fun `post-launch a non-Pro avatar is frozen`() { + assertTrue(manager(postPro = true).freezeFrameForUser(nonProContact())) + } + + @Test + fun `pre-launch a synced proof is never read, so nothing is attached or declared`() { + // Config is where another device's purchase lands, so not reading it is the whole point + val manager = manager(postPro = false) + val message = VisibleMessage(text = "hello") + + assertNull(manager.currentUserProProofForAccess()) + manager.addProFeatures(message) + + assertTrue(message.proFeatures.isEmpty()) + verify(configFactoryLazy, never()).get() + } + + @Test + fun `post-launch the proof is read from config`() { + // The control for the test above: without it, "never read" would also pass if nothing ever read it. + // The mocked config has no user configs to hand back, so the read itself throws; reaching it is enough. + runCatching { manager(postPro = true).currentUserProProofForAccess() } + + verify(configFactoryLazy).get() + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigSessionProTest.kt b/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigSessionProTest.kt new file mode 100644 index 0000000000..2fd28ea4e4 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/qa/QaLaunchConfigSessionProTest.kt @@ -0,0 +1,66 @@ +package org.thoughtcrime.securesms.qa + +import android.content.Intent +import io.mockk.mockk +import io.mockk.verify +import network.loki.messenger.BuildConfig +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.session.libsession.network.snode.SnodeDirectory +import org.session.libsession.utilities.TextSecurePreferences + +/** + * Covers the `sessionPro` launch extra, the only way a harness can turn the Pro gate on now that it is off by + * default in every build. + */ +@RunWith(RobolectricTestRunner::class) +class QaLaunchConfigSessionProTest { + + private lateinit var prefs: TextSecurePreferences + private lateinit var snodeDirectory: SnodeDirectory + + @Before + fun setUp() { + // The whole class is compiled out when this is false, so every assertion below would vacuously pass. + assertTrue( + "Unit tests must run on a variant with ALLOW_QA_LAUNCH_CONFIG=true", + BuildConfig.ALLOW_QA_LAUNCH_CONFIG + ) + + prefs = mockk(relaxed = true) + snodeDirectory = mockk(relaxed = true) + } + + private fun apply(value: String?) { + val intent = Intent().putExtra("sessionUnrelatedMarker", "1") + if (value != null) intent.putExtra("sessionPro", value) + QaLaunchConfig.apply(intent, prefs, snodeDirectory) + } + + @Test + fun `true and 1 turn Pro on`() { + apply("true") + apply("1") + + verify(exactly = 2) { prefs.setForcePostPro(true) } + } + + @Test + fun `false and 0 turn Pro off`() { + apply("false") + apply("0") + + verify(exactly = 2) { prefs.setForcePostPro(false) } + } + + @Test + fun `an unknown value or no extra leaves the setting alone`() { + apply("yes") + apply(null) + + verify(exactly = 0) { prefs.setForcePostPro(any()) } + } +}