From 42af00d7d5424605b99870083957b3581d616db4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 30 Sep 2025 16:37:28 +1000 Subject: [PATCH 001/219] Refund screens + model updates --- .../DisappearingMessagesActivity.kt | 2 +- .../settings/ConversationSettingsNavHost.kt | 2 +- .../NotificationSettingsActivity.kt | 2 +- .../securesms/groups/GroupMembersActivity.kt | 2 +- .../StartConversationSheet.kt | 2 +- .../group/CreateGroupScreen.kt | 2 +- .../securesms/media/MediaOverviewActivity.kt | 2 +- .../preferences/BlockedContactsActivity.kt | 2 +- .../appearance/AppDisguiseSettingsActivity.kt | 2 +- .../prosettings/ChoosePlanNonOriginating.kt | 8 +- .../prosettings/ProSettingsHomeScreen.kt | 2 +- .../prosettings/ProSettingsNavHost.kt | 10 +- .../prosettings/ProSettingsViewModel.kt | 11 + .../prosettings/RefundPlanNonOriginating.kt | 111 ++++++++++ .../prosettings/RefundPlanScreen.kt | 203 ++++++++++++++++++ .../subscription/NoOpSubscriptionManager.kt | 5 + .../subscription/SubscriptionCoordinator.kt | 2 + .../pro/subscription/SubscriptionManager.kt | 12 ++ .../PlayStoreSubscriptionManager.kt | 7 +- 19 files changed, 373 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt index 85dde6ae7e..b8784a3d44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.BuildConfig import org.session.libsession.messaging.messages.ExpirationConfiguration diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 291d459c04..02c288e96b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -6,7 +6,7 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.dropUnlessResumed diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt index 7c0bad06ca..623289c543 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/notification/NotificationSettingsActivity.kt @@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2.settings.notification import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt index 07e7ff27c0..e7544455c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupMembersActivity.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index f7a8c870df..6a9c7cc4f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index cf8f078478..b84c0de572 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import network.loki.messenger.R import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt index 0acbdfa303..65d4db87de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewActivity.kt @@ -5,7 +5,7 @@ import android.content.Intent import androidx.activity.viewModels import androidx.compose.runtime.Composable import androidx.core.content.IntentCompat -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt index 0141baab2d..2151c0a364 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsActivity.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.preferences import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt index fb1a9847d3..e80d58b85c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/appearance/AppDisguiseSettingsActivity.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.preferences.appearance import androidx.compose.runtime.Composable -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index e9f9f997db..31ecc22641 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -61,7 +61,7 @@ fun ChoosePlanNonOriginating( onBack = onBack, headerTitle = headerTitle, buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string .format().toString(), dangerButton = false, onButtonClick = { @@ -72,7 +72,7 @@ fun ChoosePlanNonOriginating( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PLATFORM_STORE_KEY, nonOriginatingData.store) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .format(), + .format(), //todo PRO the string has 'store' hardcoded but it should be part of the key linkCellsInfo = stringResource(R.string.updatePlanTwo), linkCells = listOf( NonOriginatingLinkCellData( @@ -89,11 +89,11 @@ fun ChoosePlanNonOriginating( ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) .format(), iconRes = R.drawable.ic_globe ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 171cf0020b..440f5d37e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -731,7 +731,7 @@ fun ProManage( iconColor = LocalColors.current.danger, qaTag = R.string.qa_pro_settings_action_request_refund, onClick = { - //todo PRO implement + sendCommand(ShowRefund) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 540656cdd5..3ff2ecad3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch @@ -94,6 +94,14 @@ fun ProSettingsNavHost( onBack = { scope.launch { navigator.navigateUp() }}, ) } + + // Refund + horizontalSlideComposable { + RefundPlanScreen( + viewModel = viewModel, + onBack = { scope.launch { navigator.navigateUp() }}, + ) + } } // Dialogs diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index cbbf3cb85d..5af06dfbdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator @@ -243,6 +244,10 @@ class ProSettingsViewModel @Inject constructor( } } + Commands.ShowRefund -> { + navigateTo(ProSettingsDestination.RefundSubscription) + } + is Commands.SetShowProBadge -> { //todo PRO implement } @@ -395,6 +400,10 @@ class ProSettingsViewModel @Inject constructor( ) } + fun getSubscriptionManager(): SubscriptionManager { + return subscriptionCoordinator.getCurrentManager() + } + private fun navigateTo(destination: ProSettingsDestination){ viewModelScope.launch { navigator.navigate(destination) @@ -408,6 +417,8 @@ class ProSettingsViewModel @Inject constructor( data object HideSimpleDialog : Commands object ShowPlanUpdate: Commands + object ShowRefund: Commands + data class SetShowProBadge(val show: Boolean): Commands data class SelectProPlan(val plan: ProPlan): Commands 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 new file mode 100644 index 0000000000..2a16f304fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanNonOriginating.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import java.time.Duration +import java.time.Instant + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlanNonOriginating( + subscription: SubscriptionType.Active, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val nonOriginatingData = subscription.nonOriginatingSubscription ?: return + val context = LocalContext.current + + BaseNonOriginatingProSettingsScreen( + disabled = true, + onBack = onBack, + headerTitle = stringResource(R.string.proRefundDescription), + buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string + .format().toString(), + dangerButton = true, + onButtonClick = { + //todo PRO implement + }, + contentTitle = Phrase.from(context.getText(R.string.proRefunding)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .format(), + linkCellsInfo = stringResource(R.string.updatePlanTwo), //todo PRO need real string + linkCells = listOf( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onDevice)) + .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .format(), + info = Phrase.from(context.getText(R.string.onDeviceDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), //todo PRO need real string + iconRes = R.drawable.ic_smartphone + ), + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.viaStoreWebsite)) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) + .format(), + info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platform) + .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .format(), //todo PRO need real string + iconRes = R.drawable.ic_globe + ) + ) + ) +} + +@Preview +@Composable +private fun PreviewUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + RefundPlanNonOriginating ( + subscription = SubscriptionType.Active.AutoRenewing( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(14), + ), + duration = ProSubscriptionDuration.THREE_MONTHS, + nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + urlSubscription = "https://www.apple.com/account/subscriptions", + ) + ), + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e8992b340e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/RefundPlanScreen.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import kotlinx.coroutines.flow.filter +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge +import org.thoughtcrime.securesms.pro.SubscriptionState +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import java.time.Duration +import java.time.Instant + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + val planData by viewModel.choosePlanState.collectAsState() + val activePlan = planData.subscriptionType as? SubscriptionType.Active + if (activePlan == null) { + onBack() + return + } + + val subManager = viewModel.getSubscriptionManager() + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform + activePlan.nonOriginatingSubscription != null -> + RefundPlanNonOriginating( + subscription = planData.subscriptionType as SubscriptionType.Active, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default refund screen + else -> RefundPlan( + data = activePlan, + isWithinQuickRefundWindow = subManager.isWithinQuickRefundWindow(), + subscriptionPlatform = subManager.platform, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun RefundPlan( + data: SubscriptionType.Active, + isWithinQuickRefundWindow: Boolean, + subscriptionPlatform: String, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + + BaseCellButtonProSettingsScreen( + disabled = true, + onBack = onBack, + buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openStoreWebsite)) + .put(PLATFORM_STORE_KEY, subscriptionPlatform) //todo PRO wrong key in string + .format().toString() + else stringResource(R.string.requestRefund), + dangerButton = true, + onButtonClick = { + //todo PRO implement + }, + title = stringResource(R.string.proRefundDescription), + ){ + Column { + Text( + text = Phrase.from(context.getText(R.string.proRefunding)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + if(isWithinQuickRefundWindow) + Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) + .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key + .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key + .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + + Text( + text = stringResource(R.string.important), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + Phrase.from(context.getText(R.string.proImportantDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } + } +} + +@Preview +@Composable +private fun PreviewRefundPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + RefundPlan( + data = SubscriptionType.Active.AutoRenewing( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(14), + ), + duration = ProSubscriptionDuration.THREE_MONTHS, + nonOriginatingSubscription = null + ), + isWithinQuickRefundWindow = false, + subscriptionPlatform = "Google", + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewQuickRefundPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + RefundPlan( + data = SubscriptionType.Active.AutoRenewing( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(14), + ), + duration = ProSubscriptionDuration.THREE_MONTHS, + nonOriginatingSubscription = null + ), + isWithinQuickRefundWindow = true, + subscriptionPlatform = "Google", + sendCommand = {}, + onBack = {}, + ) + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 566e48365b..39c1bf688b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.pro.subscription +import java.time.Duration +import java.time.Instant import javax.inject.Inject /** @@ -9,8 +11,11 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val id = "noop" override val displayName = "" override val description = "" + override val platform = "" override val iconRes = null + override val quickRefundExpiry = null + override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} override val availablePlans: List get() = emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt index 3f2816d01b..4eecc37f0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.pro.subscription import jakarta.inject.Inject +import jakarta.inject.Singleton import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @@ -8,6 +9,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent /** * Helper class to handle the selection and management of our available subscription providers */ +@Singleton class SubscriptionCoordinator @Inject constructor( private val availableManagers: Set<@JvmSuppressWildcards SubscriptionManager>, private val prefs: TextSecurePreferences diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ee22e8d082..2ab8d28a35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.pro.subscription import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import java.time.Instant /** * Represents the implementation details of a given subscription provider @@ -9,9 +10,20 @@ interface SubscriptionManager: OnAppStartupComponent { val id: String val displayName: String val description: String + val platform: String val iconRes: Int? + // Optional. Some store can have a platform specific refund window + val quickRefundExpiry: Instant? + val availablePlans: List fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) + + /** + * Returns true if a provider has a non null [quickRefundExpiry] and the current time is within that window + */ + fun isWithinQuickRefundWindow(): Boolean { + return quickRefundExpiry != null && Instant.now().isBefore(quickRefundExpiry) + } } \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 4b39a5bd6f..762dc4d501 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.withContext import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.util.CurrentActivityObserver +import java.time.Duration +import java.time.Instant import javax.inject.Inject /** @@ -28,10 +30,13 @@ class PlayStoreSubscriptionManager @Inject constructor( private val currentActivityObserver: CurrentActivityObserver, ) : SubscriptionManager { override val id = "google_play_store" - override val displayName = "" + override val displayName = "Google Play Store" override val description = "" + override val platform = "Google" override val iconRes = null + override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly + private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> From 7d25ced3120b15336715eb3784ddb7d737e5d131 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Wed, 1 Oct 2025 03:15:26 +0000 Subject: [PATCH 002/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 15 ------- app/src/main/res/values-b+cs+CZ/strings.xml | 18 -------- app/src/main/res/values-b+fr+FR/strings.xml | 14 ------ app/src/main/res/values-b+nl+NL/strings.xml | 14 ------ app/src/main/res/values-b+uk+UA/strings.xml | 3 -- app/src/main/res/values/strings.xml | 47 +++++++++++++-------- 6 files changed, 29 insertions(+), 82 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 146b346fa7..4763b42231 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -462,7 +462,6 @@ Xətanı kopyala və çıx Veri bazası xətası Nəsə səhv getdi. Lütfən daha sonra yenidən sınayın. - {pro} planını yükləmə xətası. Bilinməyən bir xəta baş verdi. Endirmək uğursuz oldu Xətalar @@ -816,7 +815,6 @@ Bu ONS-i tanıya bilmədik. Lütfən, yoxlayıb yenidən sınayın. Bu ONS-i axtara bilmədik. Lütfən, daha sonra yenidən sınayın. - {platform_store} veb saytını aç Anketi aç Digər Parol @@ -968,28 +966,18 @@ Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. {pro} planı tapılmadı Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. - Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri ödəmə tələbini göndərmək üçün eyni {platform_account} hesabını istifadə etməlisiniz. - Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəmə tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəmə tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. {pro} planını geri qaytar {pro} planını yenilə - Hazırda, {pro} planları, yalnızca {platform_store} və {platform_store} Mağazaları vasitəsilə satın alına və yenilənə bilər. {app_name} Masaüstü istifadə etdiyinizə görə planınızı burada yeniləyə bilməzsiniz.\n\n{app_pro} gəlişdiriciləri, istifadəçilərin {pro} planlarını {platform_store} və {platform_store} Mağazalarından kənarda almağına imkan verəcək alternativ ödəniş variantları üzərində ciddi şəkildə çalışırlar. {pro} Yol Xəritəsi {icon} - {platform_store} və ya {platform_store} Mağazaları vasitəsilə planınızı {app_name} quraşdırılmış və əlaqələndirilmiş cihazda {app_pro} ayarlarında yeniləyin. - {pro} üçün qeydiyyatdan keçdiyiniz {platform_account} hesabınızla {platform_store} veb saytında planınızı yeniləyin. Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin. {app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. {pro} planı bərpa edildi {app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi! - Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz. 1 ay - {monthly_price}/ay 3 ay - {monthly_price}/ay 12 ay - {monthly_price}/ay Getməyinizə məyus olduq. Geri ödəmə tələb etməzdən əvvəl bilməli olduğunuz şeylər. - {platform_account} hazırda geri ödəniş tələbinizi emal edir. Bu, adətən 24-48 saat çəkir. Onların qərarından asılı olaraq, {app_name} tətbiqində {pro} statusunuzun dəyişdiyini görə bilərsiniz. Geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəniş tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəniş tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. - Geri ödəniş tələbiniz yalnız {platform_account} veb saytında {platform_account} hesabı üzərindən icra olunacaq.\n\n{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. - Geri ödəmə tələbinizlə bağlı daha çox güncəlləmə üçün lütfən {platform_account} ilə əlaqə saxlayın. {platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz.\n\n{platform_store} Geri ödəmə dəstəyi {pro} geri ödəməsi - {app_pro} planları üçün geri ödəmələr yalnız {platform_store} Mağazası vasitəsilə {platform_account} tərəfindən həyata keçirilir.\n\n{platform_account} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. Geri ödəmə tələb edildi Daha çoxunu göndərmək üçün {pro} ayarları @@ -1011,7 +999,6 @@ Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək. Planınız {date} tarixində bitəcək.\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək. {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz? Daha güclü mesajlaşma təcrübəsi üçün {app_pro}-ya yüksəldin. - {platform_account} geri ödəmə tələbinizi emal edir Profil Ekran şəkli Ekran şəklini silmə uğursuz oldu. @@ -1067,7 +1054,6 @@ Bu, sizin geri qaytarma parolunuzdur. Kiməsə göndərsəniz, hesabınıza tam erişə bilər. Qrupu yenidən yarat Təkrar et - Başda fərqli {platform_account} vasitəsilə {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı həmin {platform_account} vasitəsilə güncəlləməlisiniz. Mesajın uzunluğunu {count} qədər azalt %1$d xarakter qaldı @@ -1212,7 +1198,6 @@ Bu URL-ni brauzerinizdə açmaq istədiyinizə əminsiniz?\n\n{url} Keçidlər, brauzerinizdə açılacaq. Sürətli rejimi istifadə et - {platform_store} veb saytı vasitəsilə Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin. Video Video oxudula bilmir. diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 7e886a43f3..962f630929 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -482,7 +482,6 @@ Zkopírovat chybu a ukončit Chyba databáze Něco se pokazilo. Zkuste to prosím později. - Chyba načítání tarifu {pro}. Došlo k neznámé chybě. Stahování selhalo Chyby @@ -860,7 +859,6 @@ Nepodařilo se rozpoznat tento ONS. Zkontrolujte ho a zkuste to znovu. Nepodařilo se vyhledat tento ONS. Zkuste to prosím později. Otevřít - Otevřít webovou stránku {platform_store} Otevřít dotazník Ostatní Heslo @@ -967,8 +965,6 @@ Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Zrušit - Zrušení vašeho tarifu {app_pro} zabrání jeho automatickému obnovení před vypršením {pro}. Zrušením {pro} nedojde k vrácení peněz. Funkce {app_pro} budete moci využívat až do vypršení platnosti vašeho tarifu.\n\n -Protože jste si původně tarif {app_pro} aktivovali přes {platform_account}, bude ke zrušení tarifu potřeba použít stejný účet {platform_account}. Dva způsoby, jak zrušit váš tarif: Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. @@ -1028,30 +1024,19 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. {pro} nebyl nalezen Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. - Protože jste se původně zaregistrovali do {app_pro} přes obchod {platform_store}, budete muset pro žádost o vrácení peněz použít stejný účet {platform_account}. - Protože jste si původně zakoupili {app_pro} přes obchod {platform_store}, váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, pokud dochází k vysokému počtu žádostí. Znovu nabýt tarif {pro} Obnovit tarif {pro} - V současnosti lze tarify {pro} zakoupit a obnovit pouze prostřednictvím obchodů {platform_store} nebo {platform_store}. Protože používáte {app_name} Desktop, nemůžete zde svůj plán obnovit.\n\nVývojáři {app_pro} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit tarify {pro} mimo obchody {platform_store} a {platform_store}. Plán vývoje {pro} {icon} - Obnovte svůj tarif v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím obchodu {platform_store} nebo {platform_store}. - Obnovte svůj tarif na webu {platform_store} pomocí účtu {platform_account}, se kterým jste si pořídili {pro}. Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}. Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}. {pro} obnoven Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven! - Protože jste se původně zaregistrovali do {app_pro} přes {platform_store}, je třeba abyste pro aktualizaci vašeho tarifu použili svůj {platform_account}. 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc Mrzí nás, že to rušíte. Než požádáte o vrácení peněz, přečtěte si informace, které byste měli vědět. - {platform_account} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24–48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit. Váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, v případě že je vyřizováno mnoho žádostí. - Vaši žádost o vrácení peněz bude vyřizovat výhradně {platform_account} prostřednictvím webových stránek {platform_account}.\n\nVzhledem k pravidlům vracení peněz {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. - Pro další informace o vaší žádosti o vrácení peněz kontaktujte prosím {platform_account}. Vzhledem k zásadám pro vrácení peněz {platform_account} nemají vývojáři aplikace {app_name} žádnou možnost ovlivnit výsledek žádosti o vrácení.\n\nPodpora vrácení peněz {platform_store} Vracení peněz za {pro} - Vrácení peněz za tarify {app_pro} je vyřizováno výhradně prostřednictvím {platform_account} v obchodě {platform_store}.\n\nVzhledem k pravidlům vracení peněz služby {platform_account} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. Obnovit {pro} Beta - V současnosti lze tarify {pro} zakoupit a obnovit pouze prostřednictvím obchodů {platform_store} nebo {platform_store}. Protože jste nainstalovali {app_name} pomocí {buildVariant}, nemůžete zde svůj tarif obnovit.\n\nVývojáři {app_pro} intenzivně pracují na alternativních platebních možnostech, které by umožnily uživatelům zakoupit tarify {pro} mimo obchody {platform_store} a {platform_store}. Plán vývoje {pro} {icon} Žádost o vrácení peněz Posílejte více se Nastavení {pro} @@ -1073,7 +1058,6 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?\n\nPo aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}. Váš tarif vyprší {date}.\n\nPo aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro. Chcete z {app_name} získat více? Navyštee na {app_pro} pro výkonnější posílání zpráv. - {platform_account} zpracovává vaši žádost o vrácení peněz Profil Zobrazovaný obrázek Chyba při odstraňování zobrazovaného obrázku. @@ -1133,7 +1117,6 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Toto je vaše heslo pro obnovení. Pokud ho někomu pošlete, bude mít plný přístup k vašemu účtu. Znovu vytvořit skupinu Znovu - Protože jste se původně zaregistrovali do {app_pro} přes jiný {platform_account}, je třeba použít ten {platform_account}, abyste aktualizovali váš tarif. Zkraťte délku zprávy o {count} Zbývá %1$d znak @@ -1284,7 +1267,6 @@ Protože jste si původně tarif {app_pro} aktivovali přes {platform_account Opravdu chcete otevřít tuto URL adresu ve vašem prohlížeči?\n\n{url} Odkazy se otevřou ve vašem prohlížeči. Použít rychlý režim - Přes webové stránky {platform_store} Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. Video Nelze přehrát video. diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index 07d92266ed..ba1d0a212d 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -811,7 +811,6 @@ Nous n\'avons pas pu reconnaître cet ONS. Veuillez vérifier et réessayer. Nous n\'avons pas pu rechercher cet ONS. Veuillez réessayer plus tard. Ouvrir - Ouvrir le site Web de {platform_store} Ouvrir le questionnaire Autre Mot de passe @@ -957,28 +956,18 @@ Votre forfait {app_pro} expirera le {date}. Forfait {pro} introuvable Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide. - Parce que vous avez initialement souscrit {app_pro} depuis {platform_store}, vous devrez utiliser le même {platform_account} pour demander un remboursement. - Comme vous vous êtes initialement inscrit à {app_pro} via le Store {platform_store}, votre demande de remboursement sera traitée par le support de {app_name}.\n\nDemandez un remboursement en cliquant sur le bouton ci-dessous et en remplissant le formulaire de demande de remboursement.\n\nBien que le support de {app_name} s\'efforce de traiter les demandes de remboursement sous 24-72 heures, le traitement peut prendre plus de temps en cas de forte demande. Récupérer le forfait {pro} Renouveler l’abonnement {pro} - Actuellement, les abonnements {pro} ne peuvent être achetés et renouvelés que via les boutiques {platform_store} et {platform_store}. Étant donné que vous utilisez {app_name} Desktop, vous ne pouvez pas renouveler votre abonnement ici.\n\nLes développeurs de {app_pro} travaillent activement sur des options de paiement alternatives pour permettre aux utilisateurs d\'acheter des abonnements {pro} en dehors des boutiques {platform_store} et {platform_store}. Feuille de route de {pro} {icon} - Renouvelez votre abonnement dans les paramètres de {app_pro} sur un appareil lié avec {app_name} installé via le {platform_store} ou le magasin {platform_store}. - Renouvelez votre abonnement sur le site Web de {platform_store} en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit à {pro}. Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}. Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}. {pro} Forfait rétabli Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré ! - Comme vous vous êtes initialement inscrit à {app_pro} via le {platform_store}, vous devez utiliser votre compte {platform_account} pour mettre à jour votre abonnement. 1 mois – {monthly_price} / mois 3 mois - {monthly_price} / mois 12 mois - {monthly_price} / mois Nous sommes désolés de vous voir partir. Voici ce que vous devez savoir avant de demander un remboursement. - {platform_account} traite actuellement votre demande de remboursement. Cela prend généralement 24-48 heures. Selon leur décision, votre statut {pro} peut changer dans {app_name}. Votre demande de remboursement sera traitée par le service de {app_name}.\n\nDemandez un remboursement en appuyant sur le bouton ci-dessous et en remplissant le formulaire de demande de remboursement.\n\nBien que le service de {app_name} fait au mieux afin de traiter les demandes de remboursement dans un délai de 24-72 heures, le traitement peut être plus long en période de forte demande. - Votre demande de remboursement sera traitée exclusivement par {platform_account} via le site Web de {platform_account}.\n\nEn raison des politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucun moyen d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approuver ou de refuser la demande, ainsi que l\'éventualité d\'un remboursement total ou partiel. - Veuillez contacter {platform_account} pour obtenir des mises à jour concernant votre demande de remboursement. En raison des politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucune possibilité d\'influencer le résultat des demandes de remboursement.\n\nAssistance pour les remboursements de {platform_store}. Remboursement de {pro} - Les remboursements pour les forfaits {app_pro} sont exclusivement gérés par {platform_account} via le Store {platform_store}.\n\nConformément aux politiques de remboursement de {platform_account}, les développeurs de {app_name} n\'ont aucun pouvoir d\'influencer l\'issue des demandes de remboursement. Cela inclut la décision d\'approbation ou de refus, ainsi que le montant total ou partiel du remboursement. Remboursement demandé Envoyez plus avec Paramètres {pro} @@ -991,7 +980,6 @@ Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?\n\nEn mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l\'accès {pro}. Votre forfait expirera le {date}.\n\nEn le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro. Vous voulez tirer le meilleur parti de {app_name} ? Passez à {app_pro} pour une expérience de messagerie améliorée. - {platform_account} traite votre demande de remboursement Profil Définir une photo de profil Échec de suppression de la photo de profil. @@ -1047,7 +1035,6 @@ Voici votre mot de passe de récupération. Si vous l\'envoyez à quelqu\'un, il aura un accès complet à votre compte. Recréer le groupe Rétablir - Comme vous vous êtes initialement inscrit à {app_pro} via un autre {platform_account}, vous devez utiliser ce {platform_account} pour mettre à jour votre abonnement. Réduis la longueur du message de {count} %1$d caractères restants @@ -1191,7 +1178,6 @@ Êtes-vous sûr de vouloir ouvrir cette adresse URL dans votre navigateur web ?\n\n{url} Les liens s\'ouvriront dans votre navigateur. Utiliser le mode rapide - Via le site Web {platform_store} Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}. Vidéo Impossible de lire la vidéo. diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 95b7f921c0..5ea7b53c9d 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -808,7 +808,6 @@ We konden deze ONS niet herkennen. Controleer deze alsjeblieft en probeer het opnieuw. We konden niet zoeken naar deze ONS. Probeer het later opnieuw. Openen - Open de {platform_store} website Enquête openen Overige Wachtwoord @@ -937,28 +936,18 @@ Je {app_pro} abonnement verloopt op {date}. {pro} abonnement niet gevonden Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp. - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je hetzelfde {platform_account} gebruiken om een terugbetaling aan te vragen. - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren {pro} abonnement herstellen {pro} abonnement verlengen - Momenteel kunnen {pro} abonnementen alleen worden gekocht en verlengd via de {platform_store}- of {platform_store} winkels. Omdat je {app_name} Desktop gebruikt, kun je je abonnement hier niet verlengen.\n\nDe ontwikkelaars van {app_pro} werken hard aan alternatieve betaalmogelijkheden, zodat gebruikers {pro} abonnementen buiten de {platform_store}- en {platform_store} winkels kunnen aanschaffen. {pro} Routekaart{icon} - Verleng je abonnement in de {app_pro} instellingen op een gekoppeld apparaat met {app_name} geïnstalleerd via de {platform_store} of {platform_store} winkel. - Verleng je abonnement op de {platform_store} website met het {platform_account} waarmee je je voor {pro} hebt aangemeld. Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies. Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}. {pro} abonnement hersteld Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld! - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je je {platform_account} gebruiken om je abonnement bij te werken. 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand Het spijt ons dat je vertrekt. Dit moet je weten voordat je een terugbetaling aanvraagt. - {platform_account} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}. Je restitutieverzoek wordt afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. - Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform_account} via de website van {platform_account}.\n\nVanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. - Neem contact op met {platform_account} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform_account} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.\n\n{platform_store} Terugbetalingsondersteuning Terugbetalen {pro} - Terugbetalingen voor {app_pro} abonnementen worden uitsluitend afgehandeld door {platform_account} via de {platform_store} Store.\n\nVanwege het restitutiebeleid van {platform_account} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. Terugbetaling aangevraagd Verstuur meer met {pro} instellingen @@ -971,7 +960,6 @@ Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?\n\nAls je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang. Je abonnement verloopt op {date}.\n\nDoor bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang. Wil je meer uit {app_name} halen? Upgrade naar {app_pro} voor een krachtigere berichtbeleving. - {platform_account} verwerkt je restitutieverzoek Profiel Toon afbeelding Verwijderen profielfoto mislukt. @@ -1027,7 +1015,6 @@ Dit is uw herstelwachtwoord. Als u het naar iemand stuurt hebben ze volledige toegang tot uw account. Groep opnieuw aanmaken Opnieuw doen - Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via een ander {platform_account}, moet je datzelfde {platform_account} gebruiken om je abonnement bij te werken. Verkort berichtlengte met {count} %1$d teken resterend @@ -1167,7 +1154,6 @@ Weet u zeker dat u deze URL in uw browser wilt openen?\n\n{url} Links worden in uw browser geopend. Gebruik Snelle Modus - Via de {platform_store} website Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website. Video Kan video niet afspelen. diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index 4939521619..e57ea49123 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -854,7 +854,6 @@ Не вдалося розпізнати цей ONS. Будь ласка, перевірте його та спробуйте ще раз. Не вдалося виконати пошук цього ONS. Спробуйте пізніше. Відкрити - Відкрити {platform_store} вебсайт Пройти опитування Інші Пароль @@ -1003,7 +1002,6 @@ Закріплення необмеженої кількості співрозмовників в головному переліку. Ваш план завершиться {date}.\n\nПісля оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro. Хочете отримати більше від {app_name}? Оновіться до {app_pro}, щоб мати потужніший досвід обміну повідомленнями. - {platform_account} опрацьовує ваш запит на відшкодування Профіль Аватар Не вдалося видалити зображення профілю @@ -1210,7 +1208,6 @@ Ви впевнені, що хочете відкрити цю URL-адресу у своєму браузері?\n\n{url} За ланкою перейде твоє оглядало мережців за промовчання. Використовувати швидкий режим - Через вебсайт {platform_store} Відео Не вдається відтворити відео. Вигляд diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6f334233c..efc14eb73a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -200,6 +200,8 @@ {app_name} needs camera access to scan QR codes Cancel Cancel Plan + Cancel your plan on the {platform} website, using the {platform_account} you signed up for Pro with. + Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for Pro with. Change Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. @@ -466,7 +468,7 @@ on startup, not your Recovery Password Copy Error and Quit Database Error Something went wrong. Please try again later. - Error loading {pro} plan. + Error loading {pro} plan An unknown error occurred. Failed to download Failures @@ -796,6 +798,8 @@ on startup, not your Recovery Password On On your {device_type} device Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. + On the {platform_store} website + On the {platform} website Create account Account Created I have an account @@ -820,7 +824,8 @@ on startup, not your Recovery Password We couldn\'t recognize this ONS. Please check it and try again. We were unable to search for this ONS. Please try again later. Open - Open {platform_store} Website + Open {platform_store} Website + Open {platform} Website Open Survey Other Password @@ -925,8 +930,7 @@ on startup, not your Recovery Password Want more pins? Organize your chats and unlock premium features with {app_pro} Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} Cancellation - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\n -Because you originally signed up for {app_pro} via a {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. + Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. Two ways to cancel your plan: Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. @@ -963,6 +967,8 @@ Because you originally signed up for {app_pro} via a {platform_account}, %1$s Longer Messages Sent This message used the following {app_pro} features: + With a new installation + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings. Currently, there are three ways to renew: {percent}% Off @@ -980,30 +986,32 @@ Because you originally signed up for {app_pro} via a {platform_account}, Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. {pro} Plan Not Found No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance. - Because you originally signed up for {app_pro} via the {platform_store} Store, you\'ll need to use the same {platform_account} to request a refund. - Because you originally signed up for {app_pro} via the {platform_store} Store, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use the same {platform_account} to request a refund. + Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. Recover {pro} Plan Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store} Store. - Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} + Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store}. + Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. + Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with. Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}. {pro} Plan Restored A valid plan for {app_pro} was detected and your {pro} status has been restored! - Because you originally signed up for {app_pro} via the {platform_store} Store, you\'ll need to use your {platform_account} to update your plan. + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your plan. 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month 12 Months - {monthly_price} / Month + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, request a refund via the {app_pro} settings. We’re sorry to see you go. Here\'s what you need to know before requesting a refund. - {platform_account} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}. + {platform} is now processing your refund request. This typically takes 24-48 hours. Depending on their decision, you may see your {pro} status change in {app_name}. Your refund request will be handled by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Your refund request will be handled exclusively by {platform_account} through the {platform_account} website.\n\nDue to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. - Please contact {platform_account} for further updates on your refund request. Due to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform_store} Refund Support + Your refund request will be handled exclusively by {platform} through the {platform} website.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform} Refund Support Refunding {pro} - Refunds for {app_pro} plans are handled exclusively by {platform_account} through the {platform_store} Store.\n\nDue to {platform_account} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store} Stores. Because you installed {app_name} using the {buildVariant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store} Stores. {pro} Roadmap {icon} + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {buildVariant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings @@ -1025,7 +1033,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access. Your plan will expire on {date}.\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access. Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. - {platform_account} is processing your refund request + {platform} is processing your refund request Profile Display Picture Failed to remove display picture. @@ -1081,7 +1089,8 @@ Because you originally signed up for {app_pro} via a {platform_account}, This is your recovery password. If you send it to someone they\'ll have full access to your account. Recreate Group Redo - Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your plan. + Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your plan. + Two ways to request a refund: Reduce message length by {count} %1$d character remaining @@ -1094,6 +1103,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, Renewing Pro Reply Request Refund + Request a refund on the {platform} website, using the {platform_account} you signed up for {pro} with. Resend Loading country information... Restart @@ -1228,7 +1238,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, Are you sure you want to open this URL in your browser?\n\n{url} Links will open in your browser. Use Fast Mode - Via the {platform_store} website + Via the {platform} website Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website. Video Unable to play video. @@ -1238,6 +1248,7 @@ Because you originally signed up for {app_pro} via a {platform_account}, This can take a few minutes. One moment please... Warning + Support for iOS 15 has ended. Update to iOS 16 or later to continue receiving app updates. Window Yes You From ddd6f335e3b0f5b32e9d56c879f1c9a2b0c5ed3f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 1 Oct 2025 13:26:16 +1000 Subject: [PATCH 003/219] UI tweaks --- .../prosettings/ChoosePlanNonOriginating.kt | 4 +-- .../prosettings/ChoosePlanScreen.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 34 ++++++++----------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index 31ecc22641..8c01863a55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -89,11 +89,11 @@ fun ChoosePlanNonOriginating( ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) + .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string .format(), iconRes = R.drawable.ic_globe ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt index 7660028ceb..7c7bdd1b47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt @@ -85,7 +85,7 @@ fun ChoosePlanScreen( onBack = onBack, ) - //todo PRO handle the case here when there are no SubscriptionManager available (for example fdroid builds) + //todo PRO handle the case here when there are no SubscriptionManager available (for example fdroid builds) OR they do but they are on a different google account from the one they purchsed the sub with // default plan chooser else -> ChoosePlan( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 440f5d37e8..a4165ba89f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -711,6 +711,18 @@ fun ProManage( Column( modifier = Modifier.fillMaxWidth(), ) { + val refundButton: @Composable ()->Unit = { + IconActionRowItem( + title = annotatedStringResource(R.string.requestRefund), + titleColor = LocalColors.current.danger, + icon = R.drawable.ic_circle_warning_custom, + iconColor = LocalColors.current.danger, + qaTag = R.string.qa_pro_settings_action_request_refund, + onClick = { + sendCommand(ShowRefund) + } + ) + } when(data){ is SubscriptionType.Active.AutoRenewing -> { IconActionRowItem( @@ -724,29 +736,11 @@ fun ProManage( } ) Divider() - IconActionRowItem( - title = annotatedStringResource(R.string.requestRefund), - titleColor = LocalColors.current.danger, - icon = R.drawable.ic_circle_warning_custom, - iconColor = LocalColors.current.danger, - qaTag = R.string.qa_pro_settings_action_request_refund, - onClick = { - sendCommand(ShowRefund) - } - ) + refundButton() } is SubscriptionType.Active.Expiring -> { - IconActionRowItem( - title = annotatedStringResource(R.string.cancelPlan), - titleColor = LocalColors.current.danger, - icon = R.drawable.ic_circle_x_custom, - iconColor = LocalColors.current.danger, - qaTag = R.string.qa_pro_settings_action_cancel_plan, - onClick = { - //todo PRO implement - } - ) + refundButton() } is SubscriptionType.Expired -> { From 559aa8cf350837f5da0869eb02f6033cc1f681b9 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 1 Oct 2025 12:52:33 +0800 Subject: [PATCH 004/219] =?UTF-8?q?Revert=20"Bump=20io.github.simophin:sql?= =?UTF-8?q?ite-web-viewer=20from=200.0.3=20to=200.2.0=E2=80=A6=20(#1568)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "Bump io.github.simophin:sqlite-web-viewer from 0.0.3 to 0.2.0 (#1560)" This reverts commit 33b55d59dd6cd1df3725352134e11904b7830940. * temp string while waiting or new ones --------- Co-authored-by: ThomasSession --- .../preferences/prosettings/ChoosePlanNonOriginating.kt | 4 +--- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index e9f9f997db..ee13567043 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -60,9 +60,7 @@ fun ChoosePlanNonOriginating( disabled = false, onBack = onBack, headerTitle = headerTitle, - buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) - .format().toString(), + buttonText = "TEMP", dangerButton = false, onButtonClick = { //todo PRO implement diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a315f92efe..1c39df287b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -168,7 +168,7 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbineVersion" huawei-push = { module = 'com.huawei.hms:push', version.ref = 'huaweiPushVersion' } google-play-review = { module = "com.google.android.play:review", version.ref = "googlePlayReviewVersion" } google-play-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewVersion" } -sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.2.0" } +sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.0.3" } protoc = { module = "com.google.protobuf:protoc", version = "4.31.1" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } From aa245cdc7d419ecc9110776e87b36b9bd6b6dfba Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 1 Oct 2025 16:20:53 +1000 Subject: [PATCH 005/219] Updated strings --- .../libsession/utilities/StringSubKeys.kt | 1 + .../prosettings/ChoosePlanNonOriginating.kt | 9 +++---- .../prosettings/ChoosePlanScreen.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 6 ++--- .../prosettings/ProSettingsViewModel.kt | 22 +++++++++++------ .../prosettings/RefundPlanNonOriginating.kt | 24 ++++++++++--------- .../prosettings/RefundPlanScreen.kt | 11 +++++---- 7 files changed, 44 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 09858a3ecd..9327d38429 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -51,6 +51,7 @@ object StringSubstitutionConstants { const val PRO_KEY: StringSubKey = "pro" const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" const val SELECTED_PLAN_KEY: StringSubKey = "selected_plan" + const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" const val PLATFORM_ACCOUNT_KEY: StringSubKey = "platform_account" const val MONTHLY_PRICE_KEY: StringSubKey = "monthly_price" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index 8c01863a55..78563044e6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -17,6 +17,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.pro.SubscriptionType @@ -60,8 +61,8 @@ fun ChoosePlanNonOriginating( disabled = false, onBack = onBack, headerTitle = headerTitle, - buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string + buttonText = Phrase.from(context.getText(R.string.onPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) .format().toString(), dangerButton = false, onButtonClick = { @@ -72,7 +73,7 @@ fun ChoosePlanNonOriginating( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PLATFORM_STORE_KEY, nonOriginatingData.store) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .format(), //todo PRO the string has 'store' hardcoded but it should be part of the key + .format(), linkCellsInfo = stringResource(R.string.updatePlanTwo), linkCells = listOf( NonOriginatingLinkCellData( @@ -89,7 +90,7 @@ fun ChoosePlanNonOriginating( ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string + .put(PLATFORM_KEY, nonOriginatingData.platform) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt index 7c7bdd1b47..0a2ece3c67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt @@ -183,7 +183,7 @@ fun ChoosePlan( text = buttonLabel, enabled = planData.enableButton, onClick = { - sendCommand(GetProPlan) + sendCommand(GoToProSettings) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index a4165ba89f..9e3a80b7e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -511,7 +511,7 @@ fun ProSettings( } }, qaTag = R.string.qa_pro_settings_action_update_plan, - onClick = { sendCommand(ShowPlanUpdate) } + onClick = { sendCommand(GoToChoosePlan) } ) Divider() @@ -719,7 +719,7 @@ fun ProManage( iconColor = LocalColors.current.danger, qaTag = R.string.qa_pro_settings_action_request_refund, onClick = { - sendCommand(ShowRefund) + sendCommand(GoToRefund) } ) } @@ -755,7 +755,7 @@ fun ProManage( iconColor = LocalColors.current.accentText, qaTag = R.string.qa_pro_settings_action_cancel_plan, onClick = { - sendCommand(ShowPlanUpdate) + sendCommand(GoToChoosePlan) } ) Divider() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 5af06dfbdd..65cfcf4fe7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import android.icu.util.MeasureUnit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.NavOptionsBuilder import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.StringSubstitutionConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY @@ -196,7 +196,7 @@ class ProSettingsViewModel @Inject constructor( } } - Commands.ShowPlanUpdate -> { + Commands.GoToChoosePlan -> { when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { @@ -244,10 +244,14 @@ class ProSettingsViewModel @Inject constructor( } } - Commands.ShowRefund -> { + Commands.GoToRefund -> { navigateTo(ProSettingsDestination.RefundSubscription) } + Commands.GoToProSettings -> { + navigateTo(ProSettingsDestination.Home) + } + is Commands.SetShowProBadge -> { //todo PRO implement } @@ -404,9 +408,12 @@ class ProSettingsViewModel @Inject constructor( return subscriptionCoordinator.getCurrentManager() } - private fun navigateTo(destination: ProSettingsDestination){ + private fun navigateTo( + destination: ProSettingsDestination, + navOptions: NavOptionsBuilder.() -> Unit = {} + ){ viewModelScope.launch { - navigator.navigate(destination) + navigator.navigate(destination, navOptions) } } @@ -416,8 +423,9 @@ class ProSettingsViewModel @Inject constructor( data object HideTCPolicyDialog: Commands data object HideSimpleDialog : Commands - object ShowPlanUpdate: Commands - object ShowRefund: Commands + object GoToChoosePlan: Commands + object GoToRefund: Commands + object GoToProSettings: Commands data class SetShowProBadge(val show: Boolean): Commands 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 2a16f304fc..e507f46bbd 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.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus @@ -39,8 +40,8 @@ fun RefundPlanNonOriginating( disabled = true, onBack = onBack, headerTitle = stringResource(R.string.proRefundDescription), - buttonText = Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) .format().toString(), dangerButton = true, onButtonClick = { @@ -54,28 +55,29 @@ fun RefundPlanNonOriginating( .put(PLATFORM_STORE_KEY, nonOriginatingData.store) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) .format(), - linkCellsInfo = stringResource(R.string.updatePlanTwo), //todo PRO need real string + linkCellsInfo = stringResource(R.string.refundRequestOptions), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) .put(DEVICE_TYPE_KEY, nonOriginatingData.device) .format(), - info = Phrase.from(context.getText(R.string.onDeviceDescription)) + info = Phrase.from(context.getText(R.string.proRefundAccountDevice)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(DEVICE_TYPE_KEY, nonOriginatingData.device) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format(), //todo PRO need real string + .format(), iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) + title = Phrase.from(context.getText(R.string.onPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) + .format(), + info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), - info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platform) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) - .format(), //todo PRO need real string iconRes = R.drawable.ic_globe ) ) 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 e8992b340e..8f6ddab3a0 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,7 @@ import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY @@ -91,8 +92,8 @@ fun RefundPlan( BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, - buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openStoreWebsite)) - .put(PLATFORM_STORE_KEY, subscriptionPlatform) //todo PRO wrong key in string + buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, subscriptionPlatform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, @@ -116,9 +117,9 @@ fun RefundPlan( text = annotatedStringResource( if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) - .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key - .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key - .put(PLATFORM_ACCOUNT_KEY, subscriptionPlatform) //todo PRO wrong key + .put(PLATFORM_KEY, subscriptionPlatform) + .put(PLATFORM_KEY, subscriptionPlatform) + .put(PLATFORM_KEY, subscriptionPlatform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) From e36adc8eb7f2c55cc7a168831460c25fb8f6591f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 1 Oct 2025 16:50:07 +1000 Subject: [PATCH 006/219] UI and logic updates --- .../securesms/preferences/SettingsScreen.kt | 1 + .../prosettings/ChoosePlanNonOriginating.kt | 4 ++- .../prosettings/ChoosePlanScreen.kt | 2 +- .../prosettings/PlanConfirmationScreen.kt | 5 ++- .../prosettings/ProSettingsViewModel.kt | 10 +++++- .../prosettings/RefundPlanNonOriginating.kt | 4 ++- .../prosettings/RefundPlanScreen.kt | 31 +++++++++++-------- .../securesms/pro/ProStatusManager.kt | 3 ++ .../securesms/pro/SubscriptionType.kt | 1 + .../subscription/NoOpSubscriptionManager.kt | 1 + .../pro/subscription/SubscriptionManager.kt | 3 +- .../PlayStoreSubscriptionManager.kt | 1 + 12 files changed, 47 insertions(+), 19 deletions(-) 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 ebedc45577..5ff35af399 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -1106,6 +1106,7 @@ private fun SettingsScreenPreview() { platform = "Apple", platformAccount = "Apple Account", urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://www.apple.com/account/subscriptions", )), refreshState = State.Success(Unit), ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index 78563044e6..de136383f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -20,6 +20,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.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.expiryFromNow @@ -66,7 +67,7 @@ fun ChoosePlanNonOriginating( .format().toString(), dangerButton = false, onButtonClick = { - //todo PRO implement + sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlSubscription)) }, contentTitle = stringResource(R.string.updatePlan), contentDescription = Phrase.from(context.getText(R.string.proPlanSignUp)) @@ -122,6 +123,7 @@ private fun PreviewUpdatePlan( platform = "Apple", platformAccount = "Apple Account", urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://www.apple.com/account/subscriptions", ) ), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt index 0a2ece3c67..7c7bdd1b47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt @@ -183,7 +183,7 @@ fun ChoosePlan( text = buttonLabel, enabled = planData.enableButton, onClick = { - sendCommand(GoToProSettings) + sendCommand(GetProPlan) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 460acc41db..0b19de09cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -35,6 +35,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToProSettings import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -131,7 +132,9 @@ fun PlanConfirmation( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), text = stringResource(R.string.theReturn), - onClick = {} + onClick = { + sendCommand(GoToProSettings) + } ) Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 65cfcf4fe7..8bb6b9e038 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -249,7 +249,15 @@ class ProSettingsViewModel @Inject constructor( } Commands.GoToProSettings -> { - navigateTo(ProSettingsDestination.Home) + // navigate back to home and pop all other screens off the stack + navigateTo( + destination = ProSettingsDestination.Home, + navOptions = { + popUpTo(ProSettingsDestination.Home){ + inclusive = true + } + } + ) } is Commands.SetShowProBadge -> { 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 e507f46bbd..242e3cfae5 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 @@ -18,6 +18,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.ui.theme.PreviewTheme @@ -45,7 +46,7 @@ fun RefundPlanNonOriginating( .format().toString(), dangerButton = true, onButtonClick = { - //todo PRO implement + sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlRefund)) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) @@ -104,6 +105,7 @@ private fun PreviewUpdatePlan( platform = "Apple", platformAccount = "Apple Account", urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://www.apple.com/account/subscriptions", ) ), sendCommand = {}, 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 8f6ddab3a0..13157acb62 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 @@ -25,11 +25,15 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STO import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlan import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -70,8 +74,7 @@ fun RefundPlanScreen( // default refund screen else -> RefundPlan( data = activePlan, - isWithinQuickRefundWindow = subManager.isWithinQuickRefundWindow(), - subscriptionPlatform = subManager.platform, + subscriptionManager = subManager, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -82,23 +85,27 @@ fun RefundPlanScreen( @Composable fun RefundPlan( data: SubscriptionType.Active, - isWithinQuickRefundWindow: Boolean, - subscriptionPlatform: String, + subscriptionManager: SubscriptionManager, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current + val isWithinQuickRefundWindow = subscriptionManager.isWithinQuickRefundWindow() BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, subscriptionPlatform) + .put(PLATFORM_KEY, subscriptionManager.platform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - //todo PRO implement + if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ + sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) + } else { + sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_REFUND)) + } }, title = stringResource(R.string.proRefundDescription), ){ @@ -117,9 +124,9 @@ fun RefundPlan( text = annotatedStringResource( if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) - .put(PLATFORM_KEY, subscriptionPlatform) - .put(PLATFORM_KEY, subscriptionPlatform) - .put(PLATFORM_KEY, subscriptionPlatform) + .put(PLATFORM_KEY, subscriptionManager.platform) + .put(PLATFORM_KEY, subscriptionManager.platform) + .put(PLATFORM_KEY, subscriptionManager.platform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) @@ -170,8 +177,7 @@ private fun PreviewRefundPlan( duration = ProSubscriptionDuration.THREE_MONTHS, nonOriginatingSubscription = null ), - isWithinQuickRefundWindow = false, - subscriptionPlatform = "Google", + subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, onBack = {}, ) @@ -193,8 +199,7 @@ private fun PreviewQuickRefundPlan( duration = ProSubscriptionDuration.THREE_MONTHS, nonOriginatingSubscription = null ), - isWithinQuickRefundWindow = true, - subscriptionPlatform = "Google", + subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, onBack = {}, ) 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 01f922ab75..1165b77a88 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -80,6 +80,7 @@ class ProStatusManager @Inject constructor( platform = "Apple", platformAccount = "Apple Account", urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://support.apple.com/118223", ) ) @@ -95,6 +96,7 @@ class ProStatusManager @Inject constructor( platform = "Apple", platformAccount = "Apple Account", urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://support.apple.com/118223", ) ) @@ -202,5 +204,6 @@ class ProStatusManager @Inject constructor( private const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" + const val URL_PRO_REFUND = "https://getsession.org/android-refund" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt index 8c8078c33f..496d718466 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt @@ -36,6 +36,7 @@ sealed interface SubscriptionType{ val platform: String, val platformAccount: String, val urlSubscription: String, + val urlRefund: String, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 39c1bf688b..fd69c6d1e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -15,6 +15,7 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val iconRes = null override val quickRefundExpiry = null + override val quickRefundUrl = null override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} override val availablePlans: List diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 2ab8d28a35..7c578e8ac2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -13,8 +13,9 @@ interface SubscriptionManager: OnAppStartupComponent { val platform: String val iconRes: Int? - // Optional. Some store can have a platform specific refund window + // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? + val quickRefundUrl: String? val availablePlans: List diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 762dc4d501..6a692aef8c 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -36,6 +36,7 @@ class PlayStoreSubscriptionManager @Inject constructor( override val iconRes = null override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly + override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" private val billingClient by lazy { BillingClient.newBuilder(application) From 8e50b6e84d5231bacb68eb9c6f24dc87592a9a2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:38:34 +0000 Subject: [PATCH 007/219] Bump daggerHiltVersion from 2.57.1 to 2.57.2 (#1564) --- 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 1c39df287b..616089a3fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,7 +36,7 @@ navVersion = "2.9.4" appcompatVersion = "1.7.1" coreVersion = "1.16.0" coroutinesVersion = "1.10.2" -daggerHiltVersion = "2.57.1" +daggerHiltVersion = "2.57.2" androidxHiltVersion = "1.3.0" glideVersion = "5.0.5" jacksonDatabindVersion = "2.9.8" From 3b72306eb634f2f14dbf8b07affb9eecca4d60f4 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:04:23 +0000 Subject: [PATCH 008/219] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index efc14eb73a..f31f464fa6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -200,6 +200,7 @@ {app_name} needs camera access to scan QR codes Cancel Cancel Plan + Cancel {pro} Plan Cancel your plan on the {platform} website, using the {platform_account} you signed up for Pro with. Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for Pro with. Change @@ -797,6 +798,7 @@ on startup, not your Recovery Password Okay On On your {device_type} device + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings. Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. On the {platform_store} website On the {platform} website @@ -929,9 +931,11 @@ on startup, not your Recovery Password Want to send longer messages? Send more text and unlock premium features with {app_pro} Want more pins? Organize your chats and unlock premium features with {app_pro} Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} + We’re sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {app_pro} plan. Cancellation Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. Two ways to cancel your plan: + Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires. Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. Your current plan is already discounted by {percent}% of the full {app_pro} price. @@ -986,7 +990,7 @@ on startup, not your Recovery Password Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. {pro} Plan Not Found No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance. - Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use the same {platform_account} to request a refund. + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to request a refund. Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. Recover {pro} Plan Renew {pro} Plan @@ -1238,6 +1242,7 @@ on startup, not your Recovery Password Are you sure you want to open this URL in your browser?\n\n{url} Links will open in your browser. Use Fast Mode + Change your plan using the {platform_account} you used to sign up with, via the {platform} website. Via the {platform} website Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website. Video From 524623a3ef6f772be401a63a2fc7291d79ea85b2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 2 Oct 2025 10:57:41 +1000 Subject: [PATCH 009/219] Cancel screen --- .../prosettings/CancelPlanNonOriginating.kt | 115 ++++++++++++++ .../prosettings/CancelPlanScreen.kt | 147 ++++++++++++++++++ .../prosettings/ProSettingsHomeScreen.kt | 2 +- .../prosettings/ProSettingsNavHost.kt | 8 + .../prosettings/ProSettingsViewModel.kt | 22 +++ .../subscription/NoOpSubscriptionManager.kt | 2 + .../pro/subscription/SubscriptionManager.kt | 2 + .../PlayStoreSubscriptionManager.kt | 2 + 8 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt new file mode 100644 index 0000000000..ea74490a51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import java.time.Duration +import java.time.Instant + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlanNonOriginating( + subscription: SubscriptionType.Active, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val nonOriginatingData = subscription.nonOriginatingSubscription ?: return + val context = LocalContext.current + + BaseNonOriginatingProSettingsScreen( + disabled = true, + onBack = onBack, + headerTitle = stringResource(R.string.proRefundDescription), + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) + .format().toString(), + dangerButton = true, + onButtonClick = { + sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlRefund)) + }, + contentTitle = Phrase.from(context.getText(R.string.proRefunding)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .format(), + linkCellsInfo = stringResource(R.string.refundRequestOptions), + linkCells = listOf( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onDevice)) + .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .format(), + info = Phrase.from(context.getText(R.string.proRefundAccountDevice)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_smartphone + ), + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) + .format(), + info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) + .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_globe + ) + ) + ) +} + +@Preview +@Composable +private fun PreviewUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + CancelPlanNonOriginating ( + subscription = SubscriptionType.Active.AutoRenewing( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(14), + ), + duration = ProSubscriptionDuration.THREE_MONTHS, + nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + urlSubscription = "https://www.apple.com/account/subscriptions", + urlRefund = "https://www.apple.com/account/subscriptions", + ) + ), + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt new file mode 100644 index 0000000000..e506847674 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OpenSubscriptionPage +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager +import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager +import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import java.time.Duration +import java.time.Instant + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + val planData by viewModel.choosePlanState.collectAsState() + val activePlan = planData.subscriptionType as? SubscriptionType.Active + if (activePlan == null) { + onBack() + return + } + + val subManager = viewModel.getSubscriptionManager() + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform + activePlan.nonOriginatingSubscription != null -> + CancelPlanNonOriginating( + subscription = planData.subscriptionType as SubscriptionType.Active, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default cancel screen + else -> CancelPlan( + data = activePlan, + subscriptionManager = subManager, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun CancelPlan( + data: SubscriptionType.Active, + subscriptionManager: SubscriptionManager, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + + BaseCellButtonProSettingsScreen( + disabled = true, + onBack = onBack, + buttonText = Phrase.from(context.getText(R.string.cancelProPlan)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + dangerButton = true, + onButtonClick = { + sendCommand(OpenSubscriptionPage) + }, + title = Phrase.from(context.getText(R.string.proCancelSorry)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString(), + ){ + Column { + Text( + text = stringResource(R.string.proCancellation), + style = LocalType.current.base.bold(), + color = LocalColors.current.text, + ) + + Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) + + Text( + text = annotatedStringResource( + Phrase.from(context.getText(R.string.proCancellationShortDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + ), + style = LocalType.current.base, + color = LocalColors.current.text, + ) + } + } +} + +@Preview +@Composable +private fun PreviewCancelPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + CancelPlan( + data = SubscriptionType.Active.AutoRenewing( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(14), + ), + duration = ProSubscriptionDuration.THREE_MONTHS, + nonOriginatingSubscription = null + ), + subscriptionManager = NoOpSubscriptionManager(), + sendCommand = {}, + onBack = {}, + ) + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 9e3a80b7e3..7c77e15093 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -732,7 +732,7 @@ fun ProManage( iconColor = LocalColors.current.danger, qaTag = R.string.qa_pro_settings_action_cancel_plan, onClick = { - //todo PRO implement + sendCommand(GoToCancel) } ) Divider() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 3ff2ecad3a..7f0da50bf9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -102,6 +102,14 @@ fun ProSettingsNavHost( onBack = { scope.launch { navigator.navigateUp() }}, ) } + + // Cancellation + horizontalSlideComposable { + CancelPlanScreen( + viewModel = viewModel, + onBack = { scope.launch { navigator.navigateUp() }}, + ) + } } // Dialogs diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 8bb6b9e038..91ff0cdd90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context +import android.content.Intent import android.icu.util.MeasureUnit +import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavOptionsBuilder @@ -26,6 +28,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.ProStatusManager @@ -40,6 +43,7 @@ import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import javax.inject.Inject +import androidx.core.net.toUri @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -248,6 +252,10 @@ class ProSettingsViewModel @Inject constructor( navigateTo(ProSettingsDestination.RefundSubscription) } + Commands.GoToCancel -> { + navigateTo(ProSettingsDestination.CancelSubscription) + } + Commands.GoToProSettings -> { // navigate back to home and pop all other screens off the stack navigateTo( @@ -260,6 +268,17 @@ class ProSettingsViewModel @Inject constructor( ) } + Commands.OpenSubscriptionPage -> { + val subUrl = subscriptionCoordinator.getCurrentManager().subscriptionUrl + if(subUrl.isNotEmpty()){ + viewModelScope.launch { + navigator.navigateToIntent( + Intent(Intent.ACTION_VIEW, subUrl.toUri()) + ) + } + } + } + is Commands.SetShowProBadge -> { //todo PRO implement } @@ -433,8 +452,11 @@ class ProSettingsViewModel @Inject constructor( object GoToChoosePlan: Commands object GoToRefund: Commands + object GoToCancel: Commands object GoToProSettings: Commands + object OpenSubscriptionPage: Commands + data class SetShowProBadge(val show: Boolean): Commands data class SelectProPlan(val plan: ProPlan): Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index fd69c6d1e5..9a3d71ceba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -14,6 +14,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val platform = "" override val iconRes = null + override val subscriptionUrl = "" + override val quickRefundExpiry = null override val quickRefundUrl = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 7c578e8ac2..dca9f2e142 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -13,6 +13,8 @@ interface SubscriptionManager: OnAppStartupComponent { val platform: String val iconRes: Int? + val subscriptionUrl: String + // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? val quickRefundUrl: String? diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 6a692aef8c..36d49cd934 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -34,6 +34,8 @@ class PlayStoreSubscriptionManager @Inject constructor( override val description = "" override val platform = "Google" override val iconRes = null + //todo PRO update to final URL once we have it + override val subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY" override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From f771b62b0597847055b682f80f14866ae63e45eb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 2 Oct 2025 13:07:17 +1000 Subject: [PATCH 010/219] Model restructure --- .../prosettings/CancelPlanNonOriginating.kt | 25 +++++++++++++------ .../prosettings/ChoosePlanNonOriginating.kt | 3 ++- .../prosettings/ProSettingsViewModel.kt | 8 +++--- .../prosettings/RefundPlanNonOriginating.kt | 3 ++- .../prosettings/RefundPlanScreen.kt | 11 ++++---- .../securesms/pro/ProStatusManager.kt | 7 +++--- .../securesms/pro/SubscriptionType.kt | 21 +++------------- .../subscription/NoOpSubscriptionManager.kt | 11 +++++--- .../pro/subscription/SubscriptionManager.kt | 15 ++++++++--- .../PlayStoreSubscriptionManager.kt | 15 ++++++++--- 10 files changed, 67 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index ea74490a51..af18b9b829 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -21,6 +21,7 @@ import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors @@ -37,10 +38,16 @@ fun CancelPlanNonOriginating( val nonOriginatingData = subscription.nonOriginatingSubscription ?: return val context = LocalContext.current + //todo PRO this should also cover the case of a google play sub from a different google account, in which case use displayName from the subscriberManager + val platform = nonOriginatingData.platform + BaseNonOriginatingProSettingsScreen( disabled = true, onBack = onBack, - headerTitle = stringResource(R.string.proRefundDescription), + headerTitle = Phrase.from(context.getText(R.string.proCancelSorry)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString(), buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) .put(PLATFORM_KEY, nonOriginatingData.platform) .format().toString(), @@ -48,21 +55,23 @@ fun CancelPlanNonOriginating( onButtonClick = { sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlRefund)) }, - contentTitle = Phrase.from(context.getText(R.string.proRefunding)) + contentTitle = stringResource(R.string.proCancellation), + contentDescription = Phrase.from(context.getText(R.string.proCancellationDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) .format(), - linkCellsInfo = stringResource(R.string.refundRequestOptions), + linkCellsInfo = stringResource(R.string.proCancellationOptions), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) .put(DEVICE_TYPE_KEY, nonOriginatingData.device) .format(), - info = Phrase.from(context.getText(R.string.proRefundAccountDevice)) + info = Phrase.from(context.getText(R.string.onDeviceCancelDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(DEVICE_TYPE_KEY, nonOriginatingData.device) .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) @@ -99,7 +108,7 @@ private fun PreviewUpdatePlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index de136383f9..189433f65d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -23,6 +23,7 @@ import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -117,7 +118,7 @@ private fun PreviewUpdatePlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 91ff0cdd90..60b1843958 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import android.icu.util.MeasureUnit -import android.net.Uri +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavOptionsBuilder @@ -28,11 +28,10 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog -import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.getDefaultSubscriptionStateData import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.SubscriptionCoordinator @@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State import javax.inject.Inject -import androidx.core.net.toUri @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -269,7 +267,7 @@ class ProSettingsViewModel @Inject constructor( } Commands.OpenSubscriptionPage -> { - val subUrl = subscriptionCoordinator.getCurrentManager().subscriptionUrl + val subUrl = subscriptionCoordinator.getCurrentManager().details.urlSubscription if(subUrl.isNotEmpty()){ viewModelScope.launch { navigator.navigateToIntent( 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 242e3cfae5..b27a66caf1 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 @@ -21,6 +21,7 @@ import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors @@ -99,7 +100,7 @@ private fun PreviewUpdatePlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", 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 13157acb62..16e3b36251 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 @@ -91,12 +91,13 @@ fun RefundPlan( ) { val context = LocalContext.current val isWithinQuickRefundWindow = subscriptionManager.isWithinQuickRefundWindow() + val platform = subscriptionManager.details.platform BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, subscriptionManager.platform) + .put(PLATFORM_KEY, platform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, @@ -104,7 +105,7 @@ fun RefundPlan( if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) } else { - sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_REFUND)) + sendCommand(ShowOpenUrlDialog(subscriptionManager.details.urlRefund)) } }, title = stringResource(R.string.proRefundDescription), @@ -124,9 +125,9 @@ fun RefundPlan( text = annotatedStringResource( if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) - .put(PLATFORM_KEY, subscriptionManager.platform) - .put(PLATFORM_KEY, subscriptionManager.platform) - .put(PLATFORM_KEY, subscriptionManager.platform) + .put(PLATFORM_KEY, platform) + .put(PLATFORM_KEY, platform) + .put(PLATFORM_KEY, platform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) 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 1165b77a88..e2b68c70d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -24,8 +24,8 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.util.State import java.time.Duration import java.time.Instant @@ -74,7 +74,7 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -90,7 +90,7 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(2), ), duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -204,6 +204,5 @@ class ProStatusManager @Inject constructor( private const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" - const val URL_PRO_REFUND = "https://getsession.org/android-refund" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt index 496d718466..840e5a0e96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.pro import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.util.State sealed interface SubscriptionType{ @@ -10,34 +11,20 @@ sealed interface SubscriptionType{ sealed interface Active: SubscriptionType{ val proStatus: ProStatus.Pro val duration: ProSubscriptionDuration - val nonOriginatingSubscription: NonOriginatingSubscription? // null if the current subscription is from the current platform + val nonOriginatingSubscription: SubscriptionDetails? // null if the current subscription is from the current platform data class AutoRenewing( override val proStatus: ProStatus.Pro, override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: NonOriginatingSubscription? + override val nonOriginatingSubscription: SubscriptionDetails? ): Active data class Expiring( override val proStatus: ProStatus.Pro, override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: NonOriginatingSubscription? + override val nonOriginatingSubscription: SubscriptionDetails? ): Active - /** - * A structure representing a non-originating subscription - * For example if a user bought Pro on their iOS device through the Apple Store - * This will help us direct them to their original subscription platform if they want - * to update or cancel Pro - */ - data class NonOriginatingSubscription( - val device: String, - val store: String, - val platform: String, - val platformAccount: String, - val urlSubscription: String, - val urlRefund: String, - ) } data object Expired: SubscriptionType diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 9a3d71ceba..bff41f27a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -9,12 +9,17 @@ import javax.inject.Inject */ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val id = "noop" - override val displayName = "" override val description = "" - override val platform = "" override val iconRes = null - override val subscriptionUrl = "" + override val details = SubscriptionDetails( + device = "", + store = "", + platform = "", + platformAccount = "", + urlSubscription = "", + urlRefund = "", + ) override val quickRefundExpiry = null override val quickRefundUrl = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index dca9f2e142..231a256322 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -8,12 +8,10 @@ import java.time.Instant */ interface SubscriptionManager: OnAppStartupComponent { val id: String - val displayName: String val description: String - val platform: String val iconRes: Int? - val subscriptionUrl: String + val details: SubscriptionDetails // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? @@ -29,4 +27,13 @@ interface SubscriptionManager: OnAppStartupComponent { fun isWithinQuickRefundWindow(): Boolean { return quickRefundExpiry != null && Instant.now().isBefore(quickRefundExpiry) } -} \ No newline at end of file +} + +data class SubscriptionDetails( + val device: String, + val store: String, + val platform: String, + val platformAccount: String, + val urlSubscription: String, + val urlRefund: String, +) \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 36d49cd934..ec7bdc32aa 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -30,12 +30,19 @@ class PlayStoreSubscriptionManager @Inject constructor( private val currentActivityObserver: CurrentActivityObserver, ) : SubscriptionManager { override val id = "google_play_store" - override val displayName = "Google Play Store" override val description = "" - override val platform = "Google" override val iconRes = null - //todo PRO update to final URL once we have it - override val subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY" + + + override val details = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + //todo PRO update to final URL once we have it + urlSubscription = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + urlRefund = "https://getsession.org/android-refund", + ) override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From f82d5cc95abf1ac1a7e62f58ffe278b51d36f815 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 2 Oct 2025 15:58:20 +1000 Subject: [PATCH 011/219] Better model to cater for nono valid subscription on originating platforms --- .../securesms/preferences/SettingsScreen.kt | 7 +- .../prosettings/CancelPlanNonOriginating.kt | 50 ++--- .../prosettings/CancelPlanScreen.kt | 15 +- .../prosettings/ChoosePlanNonOriginating.kt | 45 ++-- .../prosettings/ChoosePlanScreen.kt | 21 +- .../prosettings/ProSettingsViewModel.kt | 207 ++++++++++-------- .../prosettings/RefundPlanNonOriginating.kt | 6 +- .../prosettings/RefundPlanScreen.kt | 11 +- .../securesms/pro/ProStatusManager.kt | 8 +- .../subscription/NoOpSubscriptionManager.kt | 10 +- .../pro/subscription/SubscriptionManager.kt | 9 +- .../PlayStoreSubscriptionManager.kt | 9 +- 12 files changed, 231 insertions(+), 167 deletions(-) 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 5ff35af399..d1237fc947 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -82,6 +82,7 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.tokenpage.TokenPageActivity import org.thoughtcrime.securesms.ui.AccountIdHeader @@ -1100,13 +1101,13 @@ private fun SettingsScreenPreview() { validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionType.Active.NonOriginatingSubscription( + nonOriginatingSubscription = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://www.apple.com/account/subscriptions", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", )), refreshState = State.Success(Unit), ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index af18b9b829..ac234413a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -15,7 +15,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog @@ -31,16 +30,13 @@ import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun CancelPlanNonOriginating( - subscription: SubscriptionType.Active, + subscriptionDetails: SubscriptionDetails, + platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ - val nonOriginatingData = subscription.nonOriginatingSubscription ?: return val context = LocalContext.current - //todo PRO this should also cover the case of a google play sub from a different google account, in which case use displayName from the subscriberManager - val platform = nonOriginatingData.platform - BaseNonOriginatingProSettingsScreen( disabled = true, onBack = onBack, @@ -49,11 +45,11 @@ fun CancelPlanNonOriginating( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_KEY, platformOverride) .format().toString(), dangerButton = true, onButtonClick = { - sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlRefund)) + sendCommand(ShowOpenUrlDialog(subscriptionDetails.subscriptionUrl)) }, contentTitle = stringResource(R.string.proCancellation), contentDescription = Phrase.from(context.getText(R.string.proCancellationDescription)) @@ -62,30 +58,30 @@ fun CancelPlanNonOriginating( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .format(), linkCellsInfo = stringResource(R.string.proCancellationOptions), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .put(DEVICE_TYPE_KEY, subscriptionDetails.device) .format(), info = Phrase.from(context.getText(R.string.onDeviceCancelDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(DEVICE_TYPE_KEY, subscriptionDetails.device) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_KEY, platformOverride) .format(), info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_KEY, platformOverride) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_globe @@ -102,21 +98,15 @@ private fun PreviewUpdatePlan( PreviewTheme(colors) { val context = LocalContext.current CancelPlanNonOriginating ( - subscription = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionDetails( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://www.apple.com/account/subscriptions", - ) + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", ), + platformOverride = "Apple", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index e506847674..79b2d49ece 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -54,13 +54,24 @@ fun CancelPlanScreen( // there are different UI depending on the state when { - // there is an active subscription but from a different platform + // there is an active subscription but from a different platform activePlan.nonOriginatingSubscription != null -> CancelPlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, + subscriptionDetails = activePlan.nonOriginatingSubscription!!, + platformOverride = activePlan.nonOriginatingSubscription!!.platform, sendCommand = viewModel::onCommand, onBack = onBack, ) + + // the existing subscription manager does not have a valid subscription for this account + !planData.hasValidSubscription -> { + CancelPlanNonOriginating( + subscriptionDetails = subManager.details, + platformOverride = subManager.details.store, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } // default cancel screen else -> CancelPlan( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt index 189433f65d..db3149c2f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt @@ -35,11 +35,12 @@ import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ChoosePlanNonOriginating( - subscription: SubscriptionType.Active, + subscription: SubscriptionType, + subscriptionDetails: SubscriptionDetails, + platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ - val nonOriginatingData = subscription.nonOriginatingSubscription ?: return val context = LocalContext.current val headerTitle = when(subscription) { @@ -48,7 +49,7 @@ fun ChoosePlanNonOriginating( .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() - else -> Phrase.from(context.getText(R.string.proPlanActivatedAutoShort)) + is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proPlanActivatedAutoShort)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( context = context, @@ -57,46 +58,49 @@ fun ChoosePlanNonOriginating( )) .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() + + //todo PRO cater to EXPIRED and NEVER SUBSCRIBED here too + else -> "" } BaseNonOriginatingProSettingsScreen( disabled = false, onBack = onBack, headerTitle = headerTitle, - buttonText = Phrase.from(context.getText(R.string.onPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, platformOverride) .format().toString(), dangerButton = false, onButtonClick = { - sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlSubscription)) + sendCommand(ShowOpenUrlDialog(subscriptionDetails.subscriptionUrl)) }, contentTitle = stringResource(R.string.updatePlan), contentDescription = Phrase.from(context.getText(R.string.proPlanSignUp)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_STORE_KEY, subscriptionDetails.store) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .format(), linkCellsInfo = stringResource(R.string.updatePlanTwo), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .put(DEVICE_TYPE_KEY, subscriptionDetails.device) .format(), info = Phrase.from(context.getText(R.string.onDeviceDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(DEVICE_TYPE_KEY, subscriptionDetails.device) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.viaStoreWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_KEY, platformOverride) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) - .put(PLATFORM_STORE_KEY, nonOriginatingData.platform) //todo PRO wrong key in string + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(PLATFORM_STORE_KEY, platformOverride) .format(), iconRes = R.drawable.ic_globe ) @@ -123,10 +127,19 @@ private fun PreviewUpdatePlan( store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://www.apple.com/account/subscriptions", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", ) ), + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ), + platformOverride = "Apple", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt index 7c7bdd1b47..70a6d371ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt @@ -76,16 +76,30 @@ fun ChoosePlanScreen( val planData by viewModel.choosePlanState.collectAsState() // there are different UI depending on the state + val nonOriginatingSubscription = (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription + when { - // there is an active subscription but from a different platform - (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription != null -> + // the existing subscription manager does not have a valid subscription for this account + // there is an active subscription but from a different platform + nonOriginatingSubscription != null -> ChoosePlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, + subscriptionDetails = nonOriginatingSubscription, + platformOverride = nonOriginatingSubscription.platform, sendCommand = viewModel::onCommand, onBack = onBack, ) - //todo PRO handle the case here when there are no SubscriptionManager available (for example fdroid builds) OR they do but they are on a different google account from the one they purchsed the sub with + !planData.hasValidSubscription -> { + val subscriptionManager = viewModel.getSubscriptionManager() + ChoosePlanNonOriginating( + subscription = planData.subscriptionType, + subscriptionDetails = subscriptionManager.details, + platformOverride = subscriptionManager.details.store, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } // default plan chooser else -> ChoosePlan( @@ -138,6 +152,7 @@ fun ChoosePlan( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + //todo PRO cater for brand new subscription in here else -> "" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 60b1843958..5098f3177a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -13,7 +13,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R @@ -70,6 +75,116 @@ class ProSettingsViewModel @Inject constructor( generateState(it) } } + + // Update choosePlanState whenever proSettingsUIState changes + viewModelScope.launch { + _proSettingsUIState + .map { proState -> + val subType = proState.subscriptionState.type + val isActive = subType is SubscriptionType.Active + val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS + val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS + val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH + + ChoosePlanState( + subscriptionType = subType, + hasValidSubscription = proState.hasValidSubscription, + enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state + plans = listOf( + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) + .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) + .put(PRICE_KEY, "$47.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan12Months, + currentPlan = currentPlan12Months, + durationType = ProSubscriptionDuration.TWELVE_MONTHS, + badges = buildList { + if(currentPlan12Months){ + add( + ProPlanBadge(context.getString(R.string.currentPlan)) + ) + } + + add( + ProPlanBadge( + "33% Off", //todo PRO calculate properly + if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PERCENT_KEY, "33") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) + .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) + .put(PRICE_KEY, "$14.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan3Months, + currentPlan = currentPlan3Months, + durationType = ProSubscriptionDuration.THREE_MONTHS, + badges = buildList { + if(currentPlan3Months){ + add( + ProPlanBadge(context.getString(R.string.currentPlan)) + ) + } + + add( + ProPlanBadge( + "16% Off", //todo PRO calculate properly + if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PERCENT_KEY, "16") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceOneMonth)) + .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) + .put(PRICE_KEY, "$5") //todo PRO calculate properly + .format().toString(), + selected = currentPlan1Month, + currentPlan = currentPlan1Month, + durationType = ProSubscriptionDuration.ONE_MONTH, + badges = if(currentPlan1Month) listOf( + ProPlanBadge(context.getString(R.string.currentPlan)) + ) else emptyList(), + ), + ) + ) + } + .distinctUntilChanged() + .collect { newState -> + _choosePlanState.update { currentState -> + // Preserve the current selection if plans exist + if (currentState.plans.isNotEmpty()) { + val currentlySelectedPlan = currentState.plans.firstOrNull { it.selected } + newState.copy( + plans = newState.plans.map { plan -> + plan.copy( + selected = currentlySelectedPlan?.durationType == plan.durationType + ) + } + ) + } else { + newState + } + } + } + } } private fun generateState(subscriptionState: SubscriptionState){ @@ -80,6 +195,8 @@ class ProSettingsViewModel @Inject constructor( _proSettingsUIState.update { ProSettingsState( subscriptionState = subscriptionState, + //todo PRO need to get the product id from libsession - also this might be a long running operation + hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -101,92 +218,6 @@ class ProSettingsViewModel @Inject constructor( } ) } - - _choosePlanState.update { - val isActive = subType is SubscriptionType.Active - val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS - val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS - val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - - ChoosePlanState( - subscriptionType = subType, - enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state - plans = listOf( - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan12Months, - currentPlan = currentPlan12Months, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, - badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) - } - - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan3Months, - currentPlan = currentPlan3Months, - durationType = ProSubscriptionDuration.THREE_MONTHS, - badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) - } - - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly - .format().toString(), - selected = currentPlan1Month, - currentPlan = currentPlan1Month, - durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentPlan)) - ) else emptyList(), - ), - ) - ) - } } @@ -267,7 +298,7 @@ class ProSettingsViewModel @Inject constructor( } Commands.OpenSubscriptionPage -> { - val subUrl = subscriptionCoordinator.getCurrentManager().details.urlSubscription + val subUrl = subscriptionCoordinator.getCurrentManager().details.subscriptionUrl if(subUrl.isNotEmpty()){ viewModelScope.launch { navigator.navigateToIntent( @@ -467,12 +498,14 @@ class ProSettingsViewModel @Inject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: ProStats = ProStats(), + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" ) data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val hasValidSubscription: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) 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 b27a66caf1..ae590b36b4 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 @@ -47,7 +47,7 @@ fun RefundPlanNonOriginating( .format().toString(), dangerButton = true, onButtonClick = { - sendCommand(ShowOpenUrlDialog(nonOriginatingData.urlRefund)) + sendCommand(ShowOpenUrlDialog(nonOriginatingData.refundUrl)) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) @@ -105,8 +105,8 @@ private fun PreviewUpdatePlan( store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://www.apple.com/account/subscriptions", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", ) ), sendCommand = {}, 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 16e3b36251..66d9949542 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 @@ -14,22 +14,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase -import kotlinx.coroutines.flow.filter import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlan -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge -import org.thoughtcrime.securesms.pro.ProStatusManager -import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -105,7 +96,7 @@ fun RefundPlan( if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) } else { - sendCommand(ShowOpenUrlDialog(subscriptionManager.details.urlRefund)) + sendCommand(ShowOpenUrlDialog(subscriptionManager.details.refundUrl)) } }, title = stringResource(R.string.proRefundDescription), 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 e2b68c70d5..76aa7f6b3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -79,8 +79,8 @@ class ProStatusManager @Inject constructor( store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://support.apple.com/118223", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://support.apple.com/118223", ) ) @@ -95,8 +95,8 @@ class ProStatusManager @Inject constructor( store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", - urlSubscription = "https://www.apple.com/account/subscriptions", - urlRefund = "https://support.apple.com/118223", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://support.apple.com/118223", ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index bff41f27a7..6442394ffa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.pro.subscription -import java.time.Duration -import java.time.Instant import javax.inject.Inject /** @@ -17,8 +15,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { store = "", platform = "", platformAccount = "", - urlSubscription = "", - urlRefund = "", + subscriptionUrl = "", + refundUrl = "", ) override val quickRefundExpiry = null @@ -28,5 +26,9 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val availablePlans: List get() = emptyList() + override fun hasValidSubscription(productId: String): Boolean { + return false + } + //todo PRO test out build type with no subscription providers available - What do we show on the Pro Settings page? } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 231a256322..601de69903 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -27,6 +27,11 @@ interface SubscriptionManager: OnAppStartupComponent { fun isWithinQuickRefundWindow(): Boolean { return quickRefundExpiry != null && Instant.now().isBefore(quickRefundExpiry) } + + /** + * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API + */ + fun hasValidSubscription(productId: String): Boolean } data class SubscriptionDetails( @@ -34,6 +39,6 @@ data class SubscriptionDetails( val store: String, val platform: String, val platformAccount: String, - val urlSubscription: String, - val urlRefund: String, + val subscriptionUrl: String, + val refundUrl: String, ) \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index ec7bdc32aa..c59484b607 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.withContext import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.util.CurrentActivityObserver -import java.time.Duration import java.time.Instant import javax.inject.Inject @@ -40,8 +39,8 @@ class PlayStoreSubscriptionManager @Inject constructor( platform = "Google", platformAccount = "Google account", //todo PRO update to final URL once we have it - urlSubscription = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", - urlRefund = "https://getsession.org/android-refund", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", ) override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly @@ -150,6 +149,10 @@ class PlayStoreSubscriptionManager @Inject constructor( }) } + override fun hasValidSubscription(productId: String): Boolean { + return false //todo PRO implement properly - we should check if the api has a valid subscription matching this productId for the current google user on this phone + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } From 774ed3cbf2f278ec1657e20770e24010cb9dab8e Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 2 Oct 2025 14:33:25 +0800 Subject: [PATCH 012/219] SES-4649 : Continue button is not visible with increased font size when onboarding (notification selection screen) (#1570) --- .../MessageNotifications.kt | 112 +++++++++++------- 1 file changed, 68 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt index 3c5bb0eb75..8b3bcd7060 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/messagenotifications/MessageNotifications.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.onboarding.messagenotifications +import android.R.attr.checked +import android.R.attr.onClick import androidx.annotation.StringRes import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box @@ -7,8 +9,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -54,52 +59,65 @@ internal fun MessageNotificationsScreen( return } - if (state.showingBackWarningDialogText != null) { - OnboardingBackPressAlertDialog(dismissDialog, + if (state.showingBackWarningDialogText != null) { + OnboardingBackPressAlertDialog( + dismissDialog, textId = state.showingBackWarningDialogText, quit = quit ) } - Column { - Spacer(Modifier.weight(1f)) - Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { - Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - Text( - Phrase.from(stringResource(R.string.onboardingMessageNotificationExplanation)) - .put(APP_NAME_KEY, stringResource(R.string.app_name)) - .format().toString(), - style = LocalType.current.base - ) - Spacer(Modifier.height(LocalDimensions.current.spacing)) - } - - NotificationRadioButton( - R.string.notificationsFastMode, - if(BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei - else R.string.notificationsFastModeDescription, - modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsFastMode), - tag = R.string.recommended, - checked = state.pushEnabled, - onClick = { setEnabled(true) } - ) + val scroll = rememberScrollState() - // spacing between buttons is provided by ripple/downstate of NotificationRadioButton + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll), + contentAlignment = Alignment.Center + ) { + Column(modifier = Modifier.padding(horizontal = LocalDimensions.current.mediumSpacing)) { + Text(stringResource(R.string.notificationsMessage), style = LocalType.current.h4) + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Text( + Phrase.from(stringResource(R.string.onboardingMessageNotificationExplanation)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString(), + style = LocalType.current.base + ) + Spacer(Modifier.height(LocalDimensions.current.spacing)) + + NotificationRadioButton( + R.string.notificationsFastMode, + if (BuildConfig.FLAVOR == "huawei") R.string.notificationsFastModeDescriptionHuawei + else R.string.notificationsFastModeDescription, + modifier = Modifier + .qaTag(R.string.AccessibilityId_notificationsFastMode) + .fillMaxWidth(), + tag = R.string.recommended, + checked = state.pushEnabled, + onClick = { setEnabled(true) } + ) - val explanationTxt = Phrase.from(stringResource(R.string.notificationsSlowModeDescription)) - .put(APP_NAME_KEY, stringResource(R.string.app_name)) - .format().toString() + // spacing between buttons is provided by ripple/downstate of NotificationRadioButton - NotificationRadioButton( - stringResource(R.string.notificationsSlowMode), - explanationTxt, - modifier = Modifier.qaTag(R.string.AccessibilityId_notificationsSlowMode), - checked = state.pushDisabled, - onClick = { setEnabled(false) } - ) + val explanationTxt = + Phrase.from(stringResource(R.string.notificationsSlowModeDescription)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .format().toString() - Spacer(Modifier.weight(1f)) + NotificationRadioButton( + stringResource(R.string.notificationsSlowMode), + explanationTxt, + modifier = Modifier + .qaTag(R.string.AccessibilityId_notificationsSlowMode) + .fillMaxWidth(), + checked = state.pushDisabled, + onClick = { setEnabled(false) } + ) + } + } ContinueAccentOutlineButton(Modifier.align(Alignment.CenterHorizontally), onContinue) } @@ -116,12 +134,12 @@ private fun NotificationRadioButton( ) { // Pass-through from this string ID version to the version that takes strings NotificationRadioButton( - titleTxt = stringResource(titleId), + titleTxt = stringResource(titleId), explanationTxt = stringResource(explanationId), - modifier = modifier, - tag = tag, - checked = checked, - onClick = onClick + modifier = modifier, + tag = tag, + checked = checked, + onClick = onClick ) } @@ -138,7 +156,9 @@ private fun NotificationRadioButton( onClick = onClick, modifier = modifier, selected = checked, - contentPadding = PaddingValues(horizontal = LocalDimensions.current.mediumSpacing, vertical = 7.dp) + contentPadding = PaddingValues( + vertical = 7.dp + ) ) { Box( modifier = Modifier @@ -150,7 +170,11 @@ private fun NotificationRadioButton( ), ) { Column( - modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing, vertical = LocalDimensions.current.xsSpacing)) { + modifier = Modifier.padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xsSpacing + ) + ) { Text( titleTxt, style = LocalType.current.h8 From 96dd508f09005fa08ed14e892f62331f7bb1ad8d Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Fri, 3 Oct 2025 00:51:22 +0000 Subject: [PATCH 013/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 1 - app/src/main/res/values-b+cs+CZ/strings.xml | 2 -- app/src/main/res/values-b+fr+FR/strings.xml | 1 - app/src/main/res/values-b+nl+NL/strings.xml | 1 - app/src/main/res/values/strings.xml | 13 +++++++------ 5 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 4763b42231..c502aa2264 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -1198,7 +1198,6 @@ Bu URL-ni brauzerinizdə açmaq istədiyinizə əminsiniz?\n\n{url} Keçidlər, brauzerinizdə açılacaq. Sürətli rejimi istifadə et - Qeydiyyatdan keçərkən istifadə etdiyiniz {platform_account} hesabı ilə {platform_store} veb saytı üzərindən planınızı dəyişdirin. Video Video oxudula bilmir. Göstər diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 962f630929..b74687cd24 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -1128,7 +1128,6 @@ Odebrání hesla selhalo Odstraňte své aktuální heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí náhodně vygenerovaného klíče uloženého ve vašem zařízení. Obnovit - Obnovení Pro Odpovědět Požádat o vrácení platby Odeslat znovu @@ -1267,7 +1266,6 @@ Opravdu chcete otevřít tuto URL adresu ve vašem prohlížeči?\n\n{url} Odkazy se otevřou ve vašem prohlížeči. Použít rychlý režim - Změňte svůj tarif pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. Video Nelze přehrát video. Zobrazit diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index ba1d0a212d..f1d4c55520 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -1178,7 +1178,6 @@ Êtes-vous sûr de vouloir ouvrir cette adresse URL dans votre navigateur web ?\n\n{url} Les liens s\'ouvriront dans votre navigateur. Utiliser le mode rapide - Modifiez votre abonnement en utilisant le compte {platform_account} avec lequel vous vous êtes inscrit, via le site web de {platform_store}. Vidéo Impossible de lire la vidéo. Afficher diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 5ea7b53c9d..c823b46604 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -1154,7 +1154,6 @@ Weet u zeker dat u deze URL in uw browser wilt openen?\n\n{url} Links worden in uw browser geopend. Gebruik Snelle Modus - Wijzig je abonnement met het {platform_account} waarmee je je hebt aangemeld, via de {platform_store} website. Video Kan video niet afspelen. Bekijken diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f31f464fa6..17a61c3413 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,8 +201,8 @@ Cancel Cancel Plan Cancel {pro} Plan - Cancel your plan on the {platform} website, using the {platform_account} you signed up for Pro with. - Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for Pro with. + Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with. + Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with. Change Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. @@ -800,6 +800,7 @@ on startup, not your Recovery Password On your {device_type} device Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings. Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. + On a linked device On the {platform_store} website On the {platform} website Create account @@ -1015,7 +1016,7 @@ on startup, not your Recovery Password Refunding {pro} Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {buildVariant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings @@ -1104,7 +1105,7 @@ on startup, not your Recovery Password Failed to remove password Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device. Renew - Renewing Pro + Renewing {pro} Reply Request Refund Request a refund on the {platform} website, using the {platform_account} you signed up for {pro} with. @@ -1242,9 +1243,9 @@ on startup, not your Recovery Password Are you sure you want to open this URL in your browser?\n\n{url} Links will open in your browser. Use Fast Mode - Change your plan using the {platform_account} you used to sign up with, via the {platform} website. + Change your plan using the {platform_account} you used to sign up with, via the {platform} website . Via the {platform} website - Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website. + Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website . Video Unable to play video. View From 3132f101a3ae6c9891ee72062b0beb914d56a1e8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 3 Oct 2025 14:49:57 +1000 Subject: [PATCH 014/219] More states handling --- .../libsession/utilities/StringSubKeys.kt | 1 + .../securesms/debugmenu/DebugMenuViewModel.kt | 4 +- .../prosettings/BaseProSettingsScreens.kt | 39 ++-- .../prosettings/CancelPlanScreen.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 2 +- .../prosettings/ProSettingsNavHost.kt | 25 ++- .../prosettings/ProSettingsViewModel.kt | 15 +- .../chooseplan/ChoosePlanNoBilling.kt | 198 ++++++++++++++++++ .../ChoosePlanNonOriginating.kt | 12 +- .../{ => chooseplan}/ChoosePlanScreen.kt | 107 ++++------ .../chooseplan/GetOrRenewPlanScreen.kt | 62 ++++++ .../chooseplan/UpdatePlanScreen.kt | 62 ++++++ .../securesms/pro/ProStatusManager.kt | 14 +- .../securesms/pro/SubscriptionType.kt | 4 +- .../subscription/NoOpSubscriptionManager.kt | 2 + .../pro/subscription/SubscriptionManager.kt | 2 + .../PlayStoreSubscriptionManager.kt | 2 + 17 files changed, 454 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt rename app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/{ => chooseplan}/ChoosePlanNonOriginating.kt (92%) rename app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/{ => chooseplan}/ChoosePlanScreen.kt (86%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 9327d38429..3ad1e89517 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -47,6 +47,7 @@ object StringSubstitutionConstants { const val VERSION_KEY: StringSubKey = "version" const val LIMIT_KEY: StringSubKey = "limit" const val STORE_VARIANT_KEY: StringSubKey = "storevariant" + const val BUILD_VARIANT_KEY: StringSubKey = "build_variant" const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" 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 504ce8609c..fc58daca2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -90,6 +90,7 @@ class DebugMenuViewModel @Inject constructor( DebugSubscriptionStatus.AUTO_APPLE, DebugSubscriptionStatus.EXPIRING_APPLE, DebugSubscriptionStatus.EXPIRED, + DebugSubscriptionStatus.EXPIRED_APPLE, ), selectedDebugSubscriptionStatus = textSecurePreferences.getDebugSubscriptionType() ?: DebugSubscriptionStatus.AUTO_GOOGLE, debugProPlans = subscriptionManagers.asSequence() @@ -423,7 +424,8 @@ class DebugMenuViewModel @Inject constructor( EXPIRING_GOOGLE("Expiring/Cancelled (Google, 12 months)"), AUTO_APPLE("Auto Renewing (Apple, 1 months)"), EXPIRING_APPLE("Expiring/Cancelled (Apple, 1 months)"), - EXPIRED("Expired"), + EXPIRED("Expired (Google)"), + EXPIRED_APPLE("Expired (Apple)"), } sealed class Commands { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index cc0b28dce0..f8a056a3c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold import network.loki.messenger.R import org.thoughtcrime.securesms.ui.DialogBg +import org.thoughtcrime.securesms.ui.components.inlineContentMap /** * Base structure used in most Pro Settings screen @@ -106,7 +107,7 @@ fun BaseProSettingsScreen( fun BaseCellButtonProSettingsScreen( disabled: Boolean, onBack: () -> Unit, - buttonText: String, + buttonText: String?, dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, @@ -142,20 +143,22 @@ fun BaseCellButtonProSettingsScreen( Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - if(dangerButton) { - DangerFillButtonRect( - modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonText, - onClick = onButtonClick - ) - } else { - AccentFillButtonRect( - modifier = Modifier.fillMaxWidth() - .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonText, - onClick = onButtonClick - ) + if(buttonText != null) { + if (dangerButton) { + DangerFillButtonRect( + modifier = Modifier.fillMaxWidth() + .widthIn(max = LocalDimensions.current.maxContentWidth), + text = buttonText, + onClick = onButtonClick + ) + } else { + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth() + .widthIn(max = LocalDimensions.current.maxContentWidth), + text = buttonText, + onClick = onButtonClick + ) + } } } } @@ -191,7 +194,7 @@ private fun PreviewBaseCellButton( fun BaseNonOriginatingProSettingsScreen( disabled: Boolean, onBack: () -> Unit, - buttonText: String, + buttonText: String?, dangerButton: Boolean, onButtonClick: () -> Unit, headerTitle: CharSequence?, @@ -222,6 +225,10 @@ fun BaseNonOriginatingProSettingsScreen( text = annotatedStringResource(contentDescription), style = LocalType.current.base, color = LocalColors.current.text, + inlineContent = inlineContentMap( + textSize = LocalType.current.base.fontSize, + imageColor = LocalColors.current.text, + ), ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 79b2d49ece..7f45b59455 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -62,7 +62,7 @@ fun CancelPlanScreen( sendCommand = viewModel::onCommand, onBack = onBack, ) - + // the existing subscription manager does not have a valid subscription for this account !planData.hasValidSubscription -> { CancelPlanNonOriginating( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 7c77e15093..7db219643d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -866,7 +866,7 @@ fun PreviewProSettingsExpired( ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( subscriptionState = SubscriptionState( - type = SubscriptionType.Expired, + type = SubscriptionType.Expired(nonOriginatingSubscription = null), refreshState = State.Success(Unit), ) ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 7f0da50bf9..9e1785dbdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -12,7 +12,14 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.* +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.GetOrRenewPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.Home +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.PlanConfirmation +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.RefundSubscription +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.UpdatePlan +import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.GetOrRenewPlanScreen +import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.UpdatePlanScreen import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator @@ -24,7 +31,9 @@ sealed interface ProSettingsDestination { data object Home: ProSettingsDestination @Serializable - data object ChoosePlan: ProSettingsDestination + data object UpdatePlan: ProSettingsDestination + @Serializable + data object GetOrRenewPlan: ProSettingsDestination @Serializable data object PlanConfirmation: ProSettingsDestination @@ -80,10 +89,16 @@ fun ProSettingsNavHost( } // Subscription plan selection - horizontalSlideComposable { - ChoosePlanScreen( + horizontalSlideComposable { + UpdatePlanScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = { scope.launch { navigator.navigateUp() } }, + ) + } + horizontalSlideComposable { + GetOrRenewPlanScreen( + viewModel = viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 5098f3177a..05d23f75b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -13,12 +13,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R @@ -89,6 +86,7 @@ class ProSettingsViewModel @Inject constructor( ChoosePlanState( subscriptionType = subType, hasValidSubscription = proState.hasValidSubscription, + hasBillingCapacity = proState.hasBillingCapacity, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = listOf( ProPlan( @@ -197,6 +195,7 @@ class ProSettingsViewModel @Inject constructor( subscriptionState = subscriptionState, //todo PRO need to get the product id from libsession - also this might be a long running operation hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), + hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling, subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -272,8 +271,12 @@ class ProSettingsViewModel @Inject constructor( } } - // otherwise navigate to the "Choose plan" screen - else -> navigateTo(ProSettingsDestination.ChoosePlan) + // otherwise navigate to update or get/renew plan screen + else -> navigateTo( + if(_proSettingsUIState.value.subscriptionState.type is SubscriptionType.Active ) + ProSettingsDestination.UpdatePlan + else ProSettingsDestination.GetOrRenewPlan + ) } } @@ -498,6 +501,7 @@ class ProSettingsViewModel @Inject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: ProStats = ProStats(), + val hasBillingCapacity: Boolean = false, // true is the current build flavour supports billing val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" @@ -505,6 +509,7 @@ class ProSettingsViewModel @Inject constructor( data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val hasBillingCapacity: Boolean = false, val hasValidSubscription: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt new file mode 100644 index 0000000000..3c5e59bcd5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -0,0 +1,198 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.squareup.phrase.Phrase +import network.loki.messenger.BuildConfig +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.BUILD_VARIANT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails +import org.thoughtcrime.securesms.ui.components.iconExternalLink +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ChoosePlanNoBilling( + subscription: SubscriptionType, + subscriptionDetails: SubscriptionDetails?, + platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore + sendCommand: (ProSettingsViewModel.Commands) -> Unit, + onBack: () -> Unit, +){ + val context = LocalContext.current + + //todo PRO cater for NEVER SUBSCRIBED here + + //todo PRO currently teh same key are used so we can't display both in the same string - need to update crowdin + val defaultGoogleStore = ProStatusManager.DEFAULT_GOOGLE_STORE + val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE + + val headerTitle = when(subscription) { + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proPlanRenewStart)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + else -> "" + } + + val contentTitle = when(subscription) { + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.renewingPro)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + else -> "" + } + + val contentDescription: CharSequence = when(subscription) { + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proRenewingNoAccessBilling)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { + "fdroid" -> "F-Droid Store" + "huawei" -> "Huawei App Gallery" + else -> "APK" + }) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(ICON_KEY, iconExternalLink) + .format() + + else -> "" + } + + val cellsInfo = when(subscription) { + is SubscriptionType.Expired -> stringResource(R.string.proOptionsRenewalSubtitle) + else -> "" + } + + val cells: List = when(subscription) { + is SubscriptionType.Expired -> buildList { + addAll(listOf( + NonOriginatingLinkCellData( + title = stringResource(R.string.onLinkedDevice), + info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_link + ), + NonOriginatingLinkCellData( + title = stringResource(R.string.proNewInstallation), + info = Phrase.from(context.getText(R.string.proNewInstallationDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_smartphone + ) + )) + + if(subscriptionDetails != null) { + add( + NonOriginatingLinkCellData( + title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) + .put(PLATFORM_STORE_KEY, platformOverride) + .format(), + info = Phrase.from(context.getText(R.string.proPlanRenewPlatformStoreWebsite)) + .put(PLATFORM_STORE_KEY, platformOverride) + .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + iconRes = R.drawable.ic_globe + ) + ) + } + } + + else -> emptyList() + } + + BaseNonOriginatingProSettingsScreen( + disabled = false, + onBack = onBack, + headerTitle = headerTitle, + buttonText = if (subscriptionDetails != null) Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, platformOverride) + .format().toString() + else null, + dangerButton = false, + onButtonClick = { + subscriptionDetails?.let { + sendCommand(ShowOpenUrlDialog(it.subscriptionUrl)) + } + }, + contentTitle = contentTitle, + contentDescription = contentDescription, + linkCellsInfo = cellsInfo, + linkCells = cells + ) +} + + +@Preview +@Composable +private fun PreviewNonOrigExpiredUpdatePlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + ChoosePlanNoBilling ( + subscription = SubscriptionType.Expired(nonOriginatingSubscription = null), + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ), + platformOverride = "Apple", + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewNoBiilingBrandNewPlan( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val context = LocalContext.current + ChoosePlanNoBilling ( + subscription = SubscriptionType.Expired(nonOriginatingSubscription = null), + subscriptionDetails = null, + platformOverride = "Apple", + sendCommand = {}, + onBack = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index db3149c2f8..6749821d34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.preferences.prosettings +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -20,6 +20,9 @@ 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.session.libsession.utilities.recipients.ProStatus +import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -35,7 +38,7 @@ import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ChoosePlanNonOriginating( - subscription: SubscriptionType, + subscription: SubscriptionType.Active, subscriptionDetails: SubscriptionDetails, platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, @@ -59,7 +62,6 @@ fun ChoosePlanNonOriginating( .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() - //todo PRO cater to EXPIRED and NEVER SUBSCRIBED here too else -> "" } @@ -83,7 +85,7 @@ fun ChoosePlanNonOriginating( linkCellsInfo = stringResource(R.string.updatePlanTwo), linkCells = listOf( NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.onDevice)) + title = Phrase.from(context.getText(R.string.onDevice)) .put(DEVICE_TYPE_KEY, subscriptionDetails.device) .format(), info = Phrase.from(context.getText(R.string.onDeviceDescription)) @@ -95,7 +97,7 @@ fun ChoosePlanNonOriginating( iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.viaStoreWebsite)) + title = Phrase.from(context.getText(R.string.viaStoreWebsite)) .put(PLATFORM_KEY, platformOverride) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt similarity index 86% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 70a6d371ff..be7e0b4d91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.preferences.prosettings +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan import android.icu.util.MeasureUnit import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -6,12 +6,23 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -43,9 +54,13 @@ import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.BaseProSettingsScreen +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GetProPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.SelectProPlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowTCPolicyDialog import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlan import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.ProPlanBadge -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.expiryFromNow @@ -67,49 +82,6 @@ import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.util.DateUtils -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun ChoosePlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - val planData by viewModel.choosePlanState.collectAsState() - - // there are different UI depending on the state - val nonOriginatingSubscription = (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription - - when { - // the existing subscription manager does not have a valid subscription for this account - // there is an active subscription but from a different platform - nonOriginatingSubscription != null -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, - subscriptionDetails = nonOriginatingSubscription, - platformOverride = nonOriginatingSubscription.platform, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - !planData.hasValidSubscription -> { - val subscriptionManager = viewModel.getSubscriptionManager() - ChoosePlanNonOriginating( - subscription = planData.subscriptionType, - subscriptionDetails = subscriptionManager.details, - platformOverride = subscriptionManager.details.store, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ChoosePlan( @@ -129,7 +101,7 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.spacing)) val context = LocalContext.current - val title = when(planData.subscriptionType) { + val title = when (planData.subscriptionType) { is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proPlanRenewStart)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) @@ -143,11 +115,13 @@ fun ChoosePlan( is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proPlanActivatedAuto)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( - context = context, - amount = planData.subscriptionType.duration.duration.months, - unit = MeasureUnit.MONTH - )) + .put( + CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( + context = context, + amount = planData.subscriptionType.duration.duration.months, + unit = MeasureUnit.MONTH + ) + ) .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() @@ -163,16 +137,23 @@ fun ChoosePlan( style = LocalType.current.base, color = LocalColors.current.text, - ) + ) Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) // SUBSCRIPTIONS planData.plans.forEachIndexed { index, data -> - if(index != 0){ - Spacer(Modifier.height(if(data.badges.isNotEmpty()){ - max(LocalDimensions.current.xsSpacing, LocalDimensions.current.contentSpacing - badgeHeight/2) - } else LocalDimensions.current.contentSpacing)) + if (index != 0) { + Spacer( + Modifier.height( + if (data.badges.isNotEmpty()) { + max( + LocalDimensions.current.xsSpacing, + LocalDimensions.current.contentSpacing - badgeHeight / 2 + ) + } else LocalDimensions.current.contentSpacing + ) + ) } PlanItem( proPlan = data, @@ -186,7 +167,7 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - val buttonLabel = when(planData.subscriptionType) { + val buttonLabel = when (planData.subscriptionType) { is SubscriptionType.Expired -> context.getString(R.string.renew) is SubscriptionType.Active.Expiring -> context.getString(R.string.updatePlan) else -> context.getString(R.string.updatePlan) @@ -418,15 +399,15 @@ private fun PreviewUpdatePlanItems( ) PlanItem( - proPlan = ProSettingsViewModel.ProPlan( + proPlan = ProPlan( title = "Plan 1 with a very long title boo foo bar hello there", subtitle = "Subtitle that is also very long and is allowed to go onto another line", selected = true, currentPlan = true, durationType = ProSubscriptionDuration.TWELVE_MONTHS, badges = listOf( - ProSettingsViewModel.ProPlanBadge("Current Plan"), - ProSettingsViewModel.ProPlanBadge( + ProPlanBadge("Current Plan"), + ProPlanBadge( "20% Off but that is very long so we can test how this renders to be safe", "This is a tooltip" ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt new file mode 100644 index 0000000000..6346e0eea6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.pro.SubscriptionType + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun GetOrRenewPlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + // Renew plan + val planData by viewModel.choosePlanState.collectAsState() + val subscription = planData.subscriptionType as? SubscriptionType.Expired + // can't update a plan if the subscription isn't expired + if(subscription == null){ + onBack() + return + } + + val subscriptionManager = viewModel.getSubscriptionManager() + + // there are different UI depending on the state + val nonOriginatingSubscription = subscription.nonOriginatingSubscription + + when { + // there is an active subscription but from a different platform + nonOriginatingSubscription != null -> + ChoosePlanNoBilling( + subscription = planData.subscriptionType, + subscriptionDetails = nonOriginatingSubscription, + platformOverride = nonOriginatingSubscription.platform, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // there is an active subscription but the existing subscription manager does not have a valid product for this acount account + !planData.hasValidSubscription -> { + ChoosePlanNoBilling( + subscription = planData.subscriptionType, + subscriptionDetails = subscriptionManager.details, + platformOverride = subscriptionManager.details.store, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } +} + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt new file mode 100644 index 0000000000..6d327045ad --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.pro.SubscriptionType + + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun UpdatePlanScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + // Update plan + val planData by viewModel.choosePlanState.collectAsState() + val subscription = planData.subscriptionType as? SubscriptionType.Active + // can't update a plan if the subscription isn't currently active + if(subscription == null){ + onBack() + return + } + + val subscriptionManager = viewModel.getSubscriptionManager() + + // there are different UI depending on the state + val nonOriginatingSubscription = (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription + + when { + // there is an active subscription but from a different platform + nonOriginatingSubscription != null -> + ChoosePlanNonOriginating( + subscription = planData.subscriptionType as SubscriptionType.Active, + subscriptionDetails = nonOriginatingSubscription, + platformOverride = nonOriginatingSubscription.platform, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // there is an active subscription but the existing subscription manager does not have a valid product for this acount account + !planData.hasValidSubscription -> { + ChoosePlanNonOriginating( + subscription = planData.subscriptionType as SubscriptionType.Active, + subscriptionDetails = subscriptionManager.details, + platformOverride = subscriptionManager.details.store, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } +} + + 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 76aa7f6b3a..e844461402 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -100,7 +100,17 @@ class ProStatusManager @Inject constructor( ) ) - DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired(nonOriginatingSubscription = null) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> SubscriptionType.Expired( + nonOriginatingSubscription = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://support.apple.com/118223", + ) + ) }, // SubscriptionType.NeverSubscribed, refreshState = State.Success(Unit), @@ -204,5 +214,7 @@ class ProStatusManager @Inject constructor( private const val MAX_PIN_REGULAR = 5 // max pinned conversation for non pro users const val URL_PRO_SUPPORT = "https://getsession.org/pro-form" + const val DEFAULT_GOOGLE_STORE = "Google Play Store" + const val DEFAULT_APPLE_STORE = "Apple App Store" } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt index 840e5a0e96..639ce59029 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt @@ -27,7 +27,9 @@ sealed interface SubscriptionType{ } - data object Expired: SubscriptionType + data class Expired( + val nonOriginatingSubscription: SubscriptionDetails? + ): SubscriptionType } data class SubscriptionState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 6442394ffa..a820c24c5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -19,6 +19,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { refundUrl = "", ) + override val supportsBilling: Boolean = false + override val quickRefundExpiry = null override val quickRefundUrl = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 601de69903..09c971b7cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -13,6 +13,8 @@ interface SubscriptionManager: OnAppStartupComponent { val details: SubscriptionDetails + val supportsBilling: Boolean + // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? val quickRefundUrl: String? diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index c59484b607..776ea06cc0 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -43,6 +43,8 @@ class PlayStoreSubscriptionManager @Inject constructor( refundUrl = "https://getsession.org/android-refund", ) + override val supportsBilling: Boolean = true + override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From 4c1c34fe6c3b9f0f29993ff575c7802c2c1b4b59 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Fri, 3 Oct 2025 06:07:13 +0000 Subject: [PATCH 015/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 1 - app/src/main/res/values-b+cs+CZ/strings.xml | 1 - app/src/main/res/values/strings.xml | 10 ++++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index c502aa2264..bed5c01c63 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -203,7 +203,6 @@ Parol dəyişdirmə uğursuz oldu {app_name} üçün parolunuzu dəyişdirin. Daxili olaraq saxlanılmış verilər, yeni parolunuzla təkrar şifrələnəcək. {pro} statusu yoxlanılır - {pro} məlumatlarınız yoxlanılır. Bu səhifədəki bəzi məlumatlar, yoxlama tamamlanana qədər qeyri-dəqiq ola bilər. {pro} statusunuz yoxlanılır. Bu yoxlama tamamlandıqdan sonra {pro} ya yüksəldə biləcəksiniz. Təmizlə Hamısını təmizlə diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index b74687cd24..c289414389 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -205,7 +205,6 @@ Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. Kontrola stavu {pro} - Kontrolují se vaše údaje {pro}. Některé informace na této stránce mohou být nepřesné, dokud kontrola nebude dokončena. Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci navýšit na {pro}. Smazat Smazat vše diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17a61c3413..c8c8cb1234 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,7 +207,8 @@ Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. Checking {pro} Status - Checking your {pro} details. Some information on this page may be inaccurate until this check is complete. + Checking your {pro} details. Some information on this page may be unavailable until this check is complete. + Checking your {pro} details. You cannot renew until this check is complete. Checking your {pro} status. You\'ll be able to upgrade to {pro} once this check is complete. Clear Clear All @@ -995,8 +996,8 @@ on startup, not your Recovery Password Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. Recover {pro} Plan Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store}. + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store2}. {pro} Roadmap {icon} + Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store2}. Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with. Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. @@ -1016,7 +1017,7 @@ on startup, not your Recovery Password Refunding {pro} Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store}. {pro} Roadmap {icon} + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store2}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store2}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings @@ -1031,6 +1032,7 @@ on startup, not your Recovery Password {pro} status loading Unable to connect to the network to check your {pro} status. You cannot upgrade to {pro} until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to refresh your {pro} status. Some actions on this page will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. Need help with your {pro} plan? Submit a request to the support team. By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Unlimited Pins From 541c6bb7f0a0a69b6f744f2b420ce0cf23a4f531 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 3 Oct 2025 16:29:35 +1000 Subject: [PATCH 016/219] Reworked state --- .../securesms/preferences/SettingsScreen.kt | 7 +- .../prosettings/CancelPlanNonOriginating.kt | 15 +-- .../prosettings/CancelPlanScreen.kt | 29 +++-- .../prosettings/PlanConfirmationScreen.kt | 10 +- .../prosettings/ProSettingsHomeScreen.kt | 37 +++++- .../prosettings/ProSettingsViewModel.kt | 5 +- .../prosettings/RefundPlanNonOriginating.kt | 25 ++-- .../prosettings/RefundPlanScreen.kt | 30 +++-- .../chooseplan/ChoosePlanNoBilling.kt | 114 +++++++++--------- .../chooseplan/ChoosePlanNonOriginating.kt | 31 ++--- .../chooseplan/GetOrRenewPlanScreen.kt | 28 +---- .../chooseplan/UpdatePlanScreen.kt | 22 +--- .../securesms/pro/ProStatusManager.kt | 36 ++++-- .../securesms/pro/SubscriptionType.kt | 32 ++++- .../subscription/NoOpSubscriptionManager.kt | 10 +- .../pro/subscription/SubscriptionManager.kt | 11 +- .../PlayStoreSubscriptionManager.kt | 14 +-- 17 files changed, 235 insertions(+), 221 deletions(-) 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 d1237fc947..d5ebb4f8d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -79,10 +79,10 @@ import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogStat import org.thoughtcrime.securesms.preferences.SettingsViewModel.Commands.* import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.tokenpage.TokenPageActivity import org.thoughtcrime.securesms.ui.AccountIdHeader @@ -1101,14 +1101,15 @@ private fun SettingsScreenPreview() { validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", subscriptionUrl = "https://www.apple.com/account/subscriptions", refundUrl = "https://www.apple.com/account/subscriptions", - )), + ) + ), refreshState = State.Success(Unit), ), username = "Atreyu", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index ac234413a7..740c8c3a7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -16,22 +16,16 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog -import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import java.time.Duration -import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun CancelPlanNonOriginating( subscriptionDetails: SubscriptionDetails, - platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ @@ -45,7 +39,7 @@ fun CancelPlanNonOriginating( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, platformOverride) + .put(PLATFORM_KEY, subscriptionDetails.getPlatformDisplayName()) .format().toString(), dangerButton = true, onButtonClick = { @@ -77,10 +71,10 @@ fun CancelPlanNonOriginating( ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onPlatformWebsite)) - .put(PLATFORM_KEY, platformOverride) + .put(PLATFORM_KEY, subscriptionDetails.getPlatformDisplayName()) .format(), info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) - .put(PLATFORM_KEY, platformOverride) + .put(PLATFORM_KEY, subscriptionDetails.getPlatformDisplayName()) .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), @@ -106,7 +100,6 @@ private fun PreviewUpdatePlan( subscriptionUrl = "https://www.apple.com/account/subscriptions", refundUrl = "https://www.apple.com/account/subscriptions", ), - platformOverride = "Apple", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 7f45b59455..1133073c98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -21,6 +21,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OpenSubscriptionPage +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -54,25 +55,16 @@ fun CancelPlanScreen( // there are different UI depending on the state when { - // there is an active subscription but from a different platform - activePlan.nonOriginatingSubscription != null -> + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> CancelPlanNonOriginating( - subscriptionDetails = activePlan.nonOriginatingSubscription!!, - platformOverride = activePlan.nonOriginatingSubscription!!.platform, + subscriptionDetails = activePlan.subscriptionDetails, sendCommand = viewModel::onCommand, onBack = onBack, ) - // the existing subscription manager does not have a valid subscription for this account - !planData.hasValidSubscription -> { - CancelPlanNonOriginating( - subscriptionDetails = subManager.details, - platformOverride = subManager.details.store, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } - // default cancel screen else -> CancelPlan( data = activePlan, @@ -146,7 +138,14 @@ private fun PreviewCancelPlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) ), subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 0b19de09cd..e2246b8281 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -36,6 +36,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToProSettings +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -158,7 +159,14 @@ private fun PreviewPlanConfirmation( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) ), refreshState = State.Success(Unit),), ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 7db219643d..d47778230a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -55,6 +55,7 @@ import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -794,7 +795,14 @@ fun PreviewProSettingsPro( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) ), refreshState = State.Success(Unit), ), @@ -820,7 +828,14 @@ fun PreviewProSettingsProLoading( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) ), refreshState = State.Loading, ), @@ -846,7 +861,14 @@ fun PreviewProSettingsProError( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) ), refreshState = State.Error(Exception()), ), @@ -866,7 +888,14 @@ fun PreviewProSettingsExpired( ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( subscriptionState = SubscriptionState( - type = SubscriptionType.Expired(nonOriginatingSubscription = null), + type = SubscriptionType.Expired(SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), refreshState = State.Success(Unit), ) ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 05d23f75b3..fea5726655 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -301,8 +301,9 @@ class ProSettingsViewModel @Inject constructor( } Commands.OpenSubscriptionPage -> { - val subUrl = subscriptionCoordinator.getCurrentManager().details.subscriptionUrl - if(subUrl.isNotEmpty()){ + val subUrl = (_proSettingsUIState.value.subscriptionState.type as? SubscriptionType.Active) + ?.subscriptionDetails?.subscriptionUrl + if(!subUrl.isNullOrEmpty()){ viewModelScope.launch { navigator.navigateToIntent( Intent(Intent.ACTION_VIEW, subUrl.toUri()) 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 ae590b36b4..80ae0b0f84 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 @@ -19,9 +19,9 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STO import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors @@ -35,7 +35,6 @@ fun RefundPlanNonOriginating( sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ - val nonOriginatingData = subscription.nonOriginatingSubscription ?: return val context = LocalContext.current BaseNonOriginatingProSettingsScreen( @@ -43,41 +42,41 @@ fun RefundPlanNonOriginating( onBack = onBack, headerTitle = stringResource(R.string.proRefundDescription), buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_KEY, subscription.subscriptionDetails.platform) .format().toString(), dangerButton = true, onButtonClick = { - sendCommand(ShowOpenUrlDialog(nonOriginatingData.refundUrl)) + sendCommand(ShowOpenUrlDialog(subscription.subscriptionDetails.refundUrl)) }, contentTitle = Phrase.from(context.getText(R.string.proRefunding)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), contentDescription = Phrase.from(context.getText(R.string.proPlanPlatformRefund)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_STORE_KEY, nonOriginatingData.store) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .format(), linkCellsInfo = stringResource(R.string.refundRequestOptions), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) + .put(DEVICE_TYPE_KEY, subscription.subscriptionDetails.device) .format(), info = Phrase.from(context.getText(R.string.proRefundAccountDevice)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(DEVICE_TYPE_KEY, nonOriginatingData.device) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(DEVICE_TYPE_KEY, subscription.subscriptionDetails.device) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_smartphone ), NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) + .put(PLATFORM_KEY, subscription.subscriptionDetails.platform) .format(), info = Phrase.from(context.getText(R.string.requestRefundPlatformWebsite)) - .put(PLATFORM_KEY, nonOriginatingData.platform) - .put(PLATFORM_ACCOUNT_KEY, nonOriginatingData.platformAccount) + .put(PLATFORM_KEY, subscription.subscriptionDetails.platform) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_globe @@ -100,7 +99,7 @@ private fun PreviewUpdatePlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", 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 66d9949542..fadc4cda64 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 @@ -21,6 +21,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration @@ -55,7 +56,7 @@ fun RefundPlanScreen( // there are different UI depending on the state when { // there is an active subscription but from a different platform - activePlan.nonOriginatingSubscription != null -> + activePlan.subscriptionDetails.isFromAnotherPlatform() -> RefundPlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, sendCommand = viewModel::onCommand, @@ -82,13 +83,12 @@ fun RefundPlan( ) { val context = LocalContext.current val isWithinQuickRefundWindow = subscriptionManager.isWithinQuickRefundWindow() - val platform = subscriptionManager.details.platform BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, platform) + .put(PLATFORM_KEY, data.subscriptionDetails.platform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, @@ -96,7 +96,7 @@ fun RefundPlan( if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) } else { - sendCommand(ShowOpenUrlDialog(subscriptionManager.details.refundUrl)) + sendCommand(ShowOpenUrlDialog(data.subscriptionDetails.refundUrl)) } }, title = stringResource(R.string.proRefundDescription), @@ -116,9 +116,7 @@ fun RefundPlan( text = annotatedStringResource( if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) - .put(PLATFORM_KEY, platform) - .put(PLATFORM_KEY, platform) - .put(PLATFORM_KEY, platform) + .put(PLATFORM_KEY, data.subscriptionDetails.platform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() else Phrase.from(context.getText(R.string.proRefundRequestSessionSupport)) @@ -167,7 +165,14 @@ private fun PreviewRefundPlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) ), subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, @@ -189,7 +194,14 @@ private fun PreviewQuickRefundPlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) ), subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 3c5e59bcd5..be883d2f84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -24,8 +24,8 @@ import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCell import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.ui.components.iconExternalLink import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -35,8 +35,6 @@ import org.thoughtcrime.securesms.ui.theme.ThemeColors @Composable fun ChoosePlanNoBilling( subscription: SubscriptionType, - subscriptionDetails: SubscriptionDetails?, - platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ @@ -90,62 +88,59 @@ fun ChoosePlanNoBilling( else -> "" } - val cells: List = when(subscription) { - is SubscriptionType.Expired -> buildList { - addAll(listOf( + val cells: List = buildList { + addAll(listOf( + NonOriginatingLinkCellData( + title = stringResource(R.string.onLinkedDevice), + info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_link + ), + NonOriginatingLinkCellData( + title = stringResource(R.string.proNewInstallation), + info = Phrase.from(context.getText(R.string.proNewInstallationDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format(), + iconRes = R.drawable.ic_smartphone + ) + )) + + if(subscription is SubscriptionType.Expired) { + add( NonOriginatingLinkCellData( - title = stringResource(R.string.onLinkedDevice), - info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) - .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE_KEY, defaultAppleStore) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) + .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .format(), - iconRes = R.drawable.ic_link - ), - NonOriginatingLinkCellData( - title = stringResource(R.string.proNewInstallation), - info = Phrase.from(context.getText(R.string.proNewInstallationDescription)) - .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + info = Phrase.from(context.getText(R.string.proPlanRenewPlatformStoreWebsite)) + .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), - iconRes = R.drawable.ic_smartphone - ) - )) - - if(subscriptionDetails != null) { - add( - NonOriginatingLinkCellData( - title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) - .put(PLATFORM_STORE_KEY, platformOverride) - .format(), - info = Phrase.from(context.getText(R.string.proPlanRenewPlatformStoreWebsite)) - .put(PLATFORM_STORE_KEY, platformOverride) - .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - iconRes = R.drawable.ic_globe - ) + iconRes = R.drawable.ic_globe ) - } + ) } - - else -> emptyList() } + BaseNonOriginatingProSettingsScreen( disabled = false, onBack = onBack, headerTitle = headerTitle, - buttonText = if (subscriptionDetails != null) Phrase.from(context.getText(R.string.openPlatformWebsite)) - .put(PLATFORM_KEY, platformOverride) + buttonText = if(subscription is SubscriptionType.Expired) Phrase.from(context.getText(R.string.openPlatformWebsite)) + .put(PLATFORM_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .format().toString() else null, dangerButton = false, onButtonClick = { - subscriptionDetails?.let { - sendCommand(ShowOpenUrlDialog(it.subscriptionUrl)) + if(subscription is SubscriptionType.Expired) { + sendCommand(ShowOpenUrlDialog(subscription.subscriptionDetails.subscriptionUrl)) } }, contentTitle = contentTitle, @@ -164,16 +159,16 @@ private fun PreviewNonOrigExpiredUpdatePlan( PreviewTheme(colors) { val context = LocalContext.current ChoosePlanNoBilling ( - subscription = SubscriptionType.Expired(nonOriginatingSubscription = null), - subscriptionDetails = SubscriptionDetails( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - subscriptionUrl = "https://www.apple.com/account/subscriptions", - refundUrl = "https://www.apple.com/account/subscriptions", + subscription = SubscriptionType.Expired( + SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) ), - platformOverride = "Apple", sendCommand = {}, onBack = {}, ) @@ -188,9 +183,16 @@ private fun PreviewNoBiilingBrandNewPlan( PreviewTheme(colors) { val context = LocalContext.current ChoosePlanNoBilling ( - subscription = SubscriptionType.Expired(nonOriginatingSubscription = null), - subscriptionDetails = null, - platformOverride = "Apple", + subscription = SubscriptionType.Expired( + SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + ) + ), sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index 6749821d34..c9658dd1a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -24,9 +24,9 @@ import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProS import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -39,13 +39,13 @@ import java.time.Instant @Composable fun ChoosePlanNonOriginating( subscription: SubscriptionType.Active, - subscriptionDetails: SubscriptionDetails, - platformOverride: String, // this property is here because different scenario will require different property to be used for this string: some will use the platform, others will use the platformStore sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ){ val context = LocalContext.current + val platformOverride = subscription.subscriptionDetails.getPlatformDisplayName() + val headerTitle = when(subscription) { is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proPlanExpireDate)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) @@ -74,24 +74,24 @@ fun ChoosePlanNonOriginating( .format().toString(), dangerButton = false, onButtonClick = { - sendCommand(ShowOpenUrlDialog(subscriptionDetails.subscriptionUrl)) + sendCommand(ShowOpenUrlDialog(subscription.subscriptionDetails.subscriptionUrl)) }, contentTitle = stringResource(R.string.updatePlan), contentDescription = Phrase.from(context.getText(R.string.proPlanSignUp)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_STORE_KEY, subscriptionDetails.store) - .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.store) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .format(), linkCellsInfo = stringResource(R.string.updatePlanTwo), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) - .put(DEVICE_TYPE_KEY, subscriptionDetails.device) + .put(DEVICE_TYPE_KEY, subscription.subscriptionDetails.device) .format(), info = Phrase.from(context.getText(R.string.onDeviceDescription)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(DEVICE_TYPE_KEY, subscriptionDetails.device) - .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(DEVICE_TYPE_KEY, subscription.subscriptionDetails.device) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_smartphone @@ -101,7 +101,7 @@ fun ChoosePlanNonOriginating( .put(PLATFORM_KEY, platformOverride) .format(), info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) - .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) + .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(PLATFORM_STORE_KEY, platformOverride) .format(), iconRes = R.drawable.ic_globe @@ -124,7 +124,7 @@ private fun PreviewUpdatePlan( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -133,15 +133,6 @@ private fun PreviewUpdatePlan( refundUrl = "https://www.apple.com/account/subscriptions", ) ), - subscriptionDetails = SubscriptionDetails( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - subscriptionUrl = "https://www.apple.com/account/subscriptions", - refundUrl = "https://www.apple.com/account/subscriptions", - ), - platformOverride = "Apple", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt index 6346e0eea6..0e662d612d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt @@ -16,39 +16,15 @@ fun GetOrRenewPlanScreen( ) { // Renew plan val planData by viewModel.choosePlanState.collectAsState() - val subscription = planData.subscriptionType as? SubscriptionType.Expired - // can't update a plan if the subscription isn't expired - if(subscription == null){ - onBack() - return - } - - val subscriptionManager = viewModel.getSubscriptionManager() - - // there are different UI depending on the state - val nonOriginatingSubscription = subscription.nonOriginatingSubscription when { - // there is an active subscription but from a different platform - nonOriginatingSubscription != null -> - ChoosePlanNoBilling( - subscription = planData.subscriptionType, - subscriptionDetails = nonOriginatingSubscription, - platformOverride = nonOriginatingSubscription.platform, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // there is an active subscription but the existing subscription manager does not have a valid product for this acount account - !planData.hasValidSubscription -> { + // there are no billing options on this device + !planData.hasBillingCapacity -> ChoosePlanNoBilling( subscription = planData.subscriptionType, - subscriptionDetails = subscriptionManager.details, - platformOverride = subscriptionManager.details.store, sendCommand = viewModel::onCommand, onBack = onBack, ) - } // default plan chooser else -> ChoosePlan( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt index 6d327045ad..cd7f21730a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt @@ -25,30 +25,16 @@ fun UpdatePlanScreen( val subscriptionManager = viewModel.getSubscriptionManager() - // there are different UI depending on the state - val nonOriginatingSubscription = (planData.subscriptionType as? SubscriptionType.Active)?.nonOriginatingSubscription - when { - // there is an active subscription but from a different platform - nonOriginatingSubscription != null -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, - subscriptionDetails = nonOriginatingSubscription, - platformOverride = nonOriginatingSubscription.platform, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // there is an active subscription but the existing subscription manager does not have a valid product for this acount account - !planData.hasValidSubscription -> { + // there is an active subscription but from a different platform or from the + // same platform but a different account + subscription.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> ChoosePlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, - subscriptionDetails = subscriptionManager.details, - platformOverride = subscriptionManager.details.store, sendCommand = viewModel::onCommand, onBack = onBack, ) - } // default plan chooser else -> ChoosePlan( 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 e844461402..1dadf2f624 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -25,7 +25,6 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.util.State import java.time.Duration import java.time.Instant @@ -56,7 +55,14 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.THREE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE -> SubscriptionType.Active.Expiring( @@ -65,7 +71,14 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(2), ), duration = ProSubscriptionDuration.TWELVE_MONTHS, - nonOriginatingSubscription = null + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) ) DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> SubscriptionType.Active.AutoRenewing( @@ -74,7 +87,7 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(14), ), duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -90,7 +103,7 @@ class ProStatusManager @Inject constructor( validUntil = Instant.now() + Duration.ofDays(2), ), duration = ProSubscriptionDuration.ONE_MONTH, - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -100,9 +113,18 @@ class ProStatusManager @Inject constructor( ) ) - DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired(nonOriginatingSubscription = null) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired( + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) + ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> SubscriptionType.Expired( - nonOriginatingSubscription = SubscriptionDetails( + subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt index 639ce59029..d950f6f875 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.pro import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionDetails import org.thoughtcrime.securesms.util.State sealed interface SubscriptionType{ @@ -11,24 +10,24 @@ sealed interface SubscriptionType{ sealed interface Active: SubscriptionType{ val proStatus: ProStatus.Pro val duration: ProSubscriptionDuration - val nonOriginatingSubscription: SubscriptionDetails? // null if the current subscription is from the current platform + val subscriptionDetails: SubscriptionDetails data class AutoRenewing( override val proStatus: ProStatus.Pro, override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: SubscriptionDetails? + override val subscriptionDetails: SubscriptionDetails ): Active data class Expiring( override val proStatus: ProStatus.Pro, override val duration: ProSubscriptionDuration, - override val nonOriginatingSubscription: SubscriptionDetails? + override val subscriptionDetails: SubscriptionDetails ): Active } data class Expired( - val nonOriginatingSubscription: SubscriptionDetails? + val subscriptionDetails: SubscriptionDetails ): SubscriptionType } @@ -37,6 +36,29 @@ data class SubscriptionState( val refreshState: State, ) +data class SubscriptionDetails( + val device: String, + val store: String, + val platform: String, + val platformAccount: String, + val subscriptionUrl: String, + val refundUrl: String, +){ + fun isFromAnotherPlatform(): Boolean { + return platform.trim().lowercase() != "google" + } + + /** + * Some UI cases require a special display name for the platform. + */ + fun getPlatformDisplayName(): String { + return when(platform.trim().lowercase()){ + "google" -> store + else -> platform + } + } +} + fun getDefaultSubscriptionStateData() = SubscriptionState( type = SubscriptionType.NeverSubscribed, refreshState = State.Loading diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index a820c24c5d..8e13291492 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -7,18 +7,10 @@ import javax.inject.Inject */ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val id = "noop" + override val name = "" override val description = "" override val iconRes = null - override val details = SubscriptionDetails( - device = "", - store = "", - platform = "", - platformAccount = "", - subscriptionUrl = "", - refundUrl = "", - ) - override val supportsBilling: Boolean = false override val quickRefundExpiry = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 09c971b7cd..ab32095926 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -8,11 +8,10 @@ import java.time.Instant */ interface SubscriptionManager: OnAppStartupComponent { val id: String + val name: String val description: String val iconRes: Int? - val details: SubscriptionDetails - val supportsBilling: Boolean // Optional. Some store can have a platform specific refund window and url @@ -36,11 +35,3 @@ interface SubscriptionManager: OnAppStartupComponent { fun hasValidSubscription(productId: String): Boolean } -data class SubscriptionDetails( - val device: String, - val store: String, - val platform: String, - val platformAccount: String, - val subscriptionUrl: String, - val refundUrl: String, -) \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 776ea06cc0..db616eecc1 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -29,20 +29,10 @@ class PlayStoreSubscriptionManager @Inject constructor( private val currentActivityObserver: CurrentActivityObserver, ) : SubscriptionManager { override val id = "google_play_store" + override val name = "Google Play Store" override val description = "" override val iconRes = null - - override val details = SubscriptionDetails( - device = "Android", - store = "Google Play Store", - platform = "Google", - platformAccount = "Google account", - //todo PRO update to final URL once we have it - subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", - refundUrl = "https://getsession.org/android-refund", - ) - override val supportsBilling: Boolean = true override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly @@ -152,7 +142,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } override fun hasValidSubscription(productId: String): Boolean { - return false //todo PRO implement properly - we should check if the api has a valid subscription matching this productId for the current google user on this phone + return true //todo PRO implement properly - we should check if the api has a valid subscription matching this productId for the current google user on this phone } companion object { From 04ea74623aa20767e4f85a4f9e86cec2cf888bee Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 3 Oct 2025 16:33:58 +1000 Subject: [PATCH 017/219] Removed loader and error in settings screen --- .../securesms/preferences/SettingsScreen.kt | 121 ------------------ 1 file changed, 121 deletions(-) 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 d5ebb4f8d2..4c05297cfd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -557,37 +557,6 @@ fun Buttons( contentDescription = null, ) }, - endIcon = { - when(subscriptionState.refreshState){ - is State.Loading -> { - Box( - modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) - ) { - SmallCircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = LocalColors.current.text - ) - } - } - - is State.Error -> { - Box( - modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_triangle_alert), - tint = LocalColors.current.warning, - contentDescription = stringResource(id = R.string.qa_icon_error), - modifier = Modifier - .size(LocalDimensions.current.iconMedium) - .align(Alignment.Center), - ) - } - } - - else -> null - } - }, modifier = Modifier.qaTag(R.string.qa_settings_item_pro), colors = accentTextButtonColors() ) { @@ -1127,96 +1096,6 @@ private fun SettingsScreenPreview() { } } -@OptIn(ExperimentalSharedTransitionApi::class) -@SuppressLint("UnusedContentLambdaTargetStateParameter") -@Preview -@Composable -private fun SettingsScreenNoProPreview() { - PreviewTheme { - Settings ( - uiState = SettingsViewModel.UIState( - showLoader = false, - avatarDialogState = SettingsViewModel.AvatarDialogState.NoAvatar, - recoveryHidden = false, - showUrlDialog = null, - showAvatarDialog = false, - showAvatarPickerOptionCamera = false, - showAvatarPickerOptions = false, - showAnimatedProCTA = false, - avatarData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TO", - color = primaryBlue - ) - ) - ), - isPro = false, - isPostPro = true, - subscriptionState = SubscriptionState( - type = SubscriptionType.NeverSubscribed, - refreshState = State.Loading, - ), - username = "Atreyu", - accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - hasPath = true, - version = "1.26.0", - ), - sendCommand = {}, - onGalleryPicked = {}, - onCameraPicked = {}, - startAvatarSelection = {}, - onBack = {}, - - ) - } -} - -@OptIn(ExperimentalSharedTransitionApi::class) -@SuppressLint("UnusedContentLambdaTargetStateParameter") -@Preview -@Composable -private fun SettingsScreenProExpiredPreview() { - PreviewTheme { - Settings ( - uiState = SettingsViewModel.UIState( - showLoader = false, - avatarDialogState = SettingsViewModel.AvatarDialogState.NoAvatar, - recoveryHidden = false, - showUrlDialog = null, - showAvatarDialog = false, - showAvatarPickerOptionCamera = false, - showAvatarPickerOptions = false, - showAnimatedProCTA = false, - avatarData = AvatarUIData( - listOf( - AvatarUIElement( - name = "TO", - color = primaryBlue - ) - ) - ), - isPro = true, - isPostPro = true, - subscriptionState = SubscriptionState( - type = SubscriptionType.NeverSubscribed, - refreshState = State.Error(Exception()), - ), - username = "Atreyu", - accountID = "053d30141d0d35d9c4b30a8f8880f8464e221ee71a8aff9f0dcefb1e60145cea5144", - hasPath = true, - version = "1.26.0", - ), - sendCommand = {}, - onGalleryPicked = {}, - onCameraPicked = {}, - startAvatarSelection = {}, - onBack = {}, - - ) - } -} - @Preview @Composable fun PreviewAvatarDialog( From 9471ad3121772eb10fc59e1ba84414c7805d7f8d Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Fri, 3 Oct 2025 07:09:18 +0000 Subject: [PATCH 018/219] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8c8cb1234..03d4dd34b8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -996,8 +996,8 @@ on startup, not your Recovery Password Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. Recover {pro} Plan Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store2}. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store2}. + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with. Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. @@ -1017,7 +1017,7 @@ on startup, not your Recovery Password Refunding {pro} Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store2}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store2}. {pro} Roadmap {icon} + Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings From 37270ea425dbe37b3d9969ac347fa3bcf49a54af Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 09:19:20 +1100 Subject: [PATCH 019/219] Updated string key --- .../java/org/session/libsession/utilities/StringSubKeys.kt | 1 + .../prosettings/chooseplan/ChoosePlanNoBilling.kt | 7 ++++--- .../preferences/prosettings/chooseplan/UpdatePlanScreen.kt | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 3ad1e89517..6c5d8e695f 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -54,6 +54,7 @@ object StringSubstitutionConstants { const val SELECTED_PLAN_KEY: StringSubKey = "selected_plan" const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" + const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other" const val PLATFORM_ACCOUNT_KEY: StringSubKey = "platform_account" const val MONTHLY_PRICE_KEY: StringSubKey = "monthly_price" const val PRICE_KEY: StringSubKey = "price" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index be883d2f84..5fedf8c362 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -17,6 +17,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.BUILD_VARIAN import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE2_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen @@ -65,7 +66,7 @@ fun ChoosePlanNoBilling( is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proRenewingNoAccessBilling)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { "fdroid" -> "F-Droid Store" @@ -75,7 +76,7 @@ fun ChoosePlanNoBilling( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(ICON_KEY, iconExternalLink) .format() @@ -95,7 +96,7 @@ fun ChoosePlanNoBilling( info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE_KEY, defaultAppleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_link diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt index cd7f21730a..38adba3087 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt @@ -28,8 +28,10 @@ fun UpdatePlanScreen( when { // there is an active subscription but from a different platform or from the // same platform but a different account + // or we have no billing APIs subscription.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription -> + || !planData.hasValidSubscription + || !subscriptionManager.supportsBilling -> ChoosePlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, sendCommand = viewModel::onCommand, From 1ece25449081a54abefc104cfaf45bb664fc446b Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 6 Oct 2025 07:05:26 +0800 Subject: [PATCH 020/219] SES-4622 : Opening a Notification can scroll conversation to top [Crash fix] (#1574) * added extension function to defer scrolling * Added fallback address when extras get stripped * update intent flags --- .../conversation/v2/ConversationActivityV2.kt | 17 +++++++++++++++-- .../notifications/NotificationItem.java | 12 +++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 9fa6a86079..f8d946cc74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -283,9 +283,22 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } private val address: Address.Conversable by lazy { - requireNotNull(IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java)) { - "Address must be provided in the intent extras" + val fromExtras = + IntentCompat.getParcelableExtra(intent, ADDRESS, Address.Conversable::class.java) + if (fromExtras != null) { + return@lazy fromExtras } + + // Fallback: parse from URI + val serialized = intent.data?.getQueryParameter(ADDRESS) + if (!serialized.isNullOrEmpty()) { + val parsed = fromSerialized(serialized) + if (parsed is Address.Conversable) { + return@lazy parsed + } + } + + throw IllegalArgumentException("Address must be provided in the intent extras or URI") } private val viewModel: ConversationViewModel by viewModels(extrasProducer = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java index 0454cb8599..c0042fb271 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationItem.java @@ -72,10 +72,12 @@ public long getThreadId() { } public PendingIntent getPendingIntent(Context context) { - - Recipient notifyRecipients = threadRecipient != null ? threadRecipient : conversationRecipient; + Recipient notifyRecipients = getRecipient(); final Intent intent = ConversationActivityV2.Companion.createIntent(context, (Address.Conversable) notifyRecipients.getAddress()); - intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); + intent.setData(Uri.parse("custom://" + System.currentTimeMillis()) + .buildUpon() + .appendQueryParameter("address", notifyRecipients.getAddress().toString()) + .build()); int intentFlags = PendingIntent.FLAG_UPDATE_CURRENT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -83,8 +85,8 @@ public PendingIntent getPendingIntent(Context context) { } return TaskStackBuilder.create(context) - .addNextIntentWithParentStack(intent) - .getPendingIntent(0, intentFlags); + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, intentFlags); } public long getId() { From 12e6a695e31a8ac9ad040759df56e696a1c1c3ab Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 10:56:48 +1100 Subject: [PATCH 021/219] Loading and error state for renew --- .../prosettings/ProSettingsHomeScreen.kt | 58 ++++++++++++++++--- .../securesms/pro/ProStatusManager.kt | 2 +- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index d47778230a..57ef28fe01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -199,6 +199,7 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) ProManage( data = subscriptionType, + subscriptionRefreshState = data.subscriptionState.refreshState, sendCommand = sendCommand, ) } @@ -215,6 +216,7 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) ProManage( data = subscriptionType, + subscriptionRefreshState = data.subscriptionState.refreshState, sendCommand = sendCommand, ) } @@ -700,6 +702,7 @@ private fun ProFeatureItem( fun ProManage( modifier: Modifier = Modifier, data: SubscriptionType, + subscriptionRefreshState: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ CategoryCell( @@ -745,20 +748,59 @@ fun ProManage( } is SubscriptionType.Expired -> { - IconActionRowItem( + // the details depend on the loading/error state + val renewIcon: @Composable BoxScope.() -> Unit = { + Icon( + modifier = Modifier.align(Alignment.Center) + .size(LocalDimensions.current.iconMedium) + .qaTag(R.string.qa_action_item_icon), + painter = painterResource(id = R.drawable.ic_circle_plus), + contentDescription = null, + tint = LocalColors.current.text + ) + } + + val (subtitle, subColor, icon) = when(subscriptionRefreshState){ + is State.Loading -> Triple Unit>( + Phrase.from(LocalContext.current, R.string.proPlanLoadingEllipsis) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + LocalColors.current.text, + { SmallCircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + ) + + is State.Error -> Triple Unit>( + Phrase.from(LocalContext.current, R.string.errorLoadingProPlan) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + LocalColors.current.warning, renewIcon + ) + + is State.Success<*> -> Triple Unit>( + null, + LocalColors.current.text, renewIcon + ) + } + + ActionRowItem( title = annotatedStringResource( Phrase.from(LocalContext.current, R.string.proPlanRenew) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), - titleColor = LocalColors.current.accentText, - icon = R.drawable.ic_circle_plus, - iconColor = LocalColors.current.accentText, - qaTag = R.string.qa_pro_settings_action_cancel_plan, - onClick = { - sendCommand(GoToChoosePlan) - } + subtitle = if(subtitle == null) null else annotatedStringResource(subtitle), + subtitleColor = subColor, + endContent = { + Box( + modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) + ) { + icon() + } + }, + qaTag = R.string.qa_pro_settings_action_renew_plan, + onClick = { sendCommand(GoToChoosePlan) } ) + Divider() IconActionRowItem( title = annotatedStringResource( 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 1dadf2f624..2a9928c22b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -135,7 +135,7 @@ class ProStatusManager @Inject constructor( ) }, // SubscriptionType.NeverSubscribed, - refreshState = State.Success(Unit), + refreshState = State.Error(Exception()), ) }.stateIn(GlobalScope, SharingStarted.Eagerly, From e49aaf3b61364b7aae856f1f4d95e8012410f321 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 11:25:03 +1100 Subject: [PATCH 022/219] Pro Stats loading state --- .../prosettings/ProSettingsHomeScreen.kt | 61 ++++++++++++------- .../prosettings/ProSettingsViewModel.kt | 12 +++- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 57ef28fe01..66027c2bcc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -280,7 +280,7 @@ fun ProSettingsHome( @Composable fun ProStats( modifier: Modifier = Modifier, - data: ProSettingsViewModel.ProStats, + data: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ CategoryCell( @@ -321,6 +321,8 @@ fun ProStats( .padding(LocalDimensions.current.smallSpacing), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ){ + val stats = (data as? State.Success)?.value + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) @@ -330,9 +332,11 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proLongerMessagesSent, - data.longMessages, - NumberUtil.getFormattedNumber(data.longMessages.toLong()) - ), + stats?.longMessages ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.longMessages.toLong()) + else "" + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_message_square ) @@ -341,9 +345,11 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proPinnedConversations, - data.pinnedConversations, - NumberUtil.getFormattedNumber(data.pinnedConversations.toLong()) - ), + stats?.pinnedConversations ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.pinnedConversations.toLong()) + else "" + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_pin ) } @@ -357,10 +363,12 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proBadgesSent, - data.proBadges, - NumberUtil.getFormattedNumber(data.proBadges.toLong()), + stats?.proBadges ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.proBadges.toLong()) + else "", NonTranslatableStringConstants.PRO - ), + ).trim(), + loading = data !is State.Success, icon = R.drawable.ic_rectangle_ellipsis ) @@ -370,11 +378,13 @@ fun ProStats( modifier = Modifier.weight(1f), title = pluralStringResource( R.plurals.proGroupsUpgraded, - data.groupsUpdated, - NumberUtil.getFormattedNumber(data.groupsUpdated.toLong()) - ), + stats?.groupsUpdated ?: 0, + if(stats != null) NumberUtil.getFormattedNumber(stats.groupsUpdated.toLong()) + else "" + ).trim(), icon = R.drawable.ic_users_group_custom, disabled = true, + loading = data !is State.Success, tooltip = stringResource(R.string.proLargerGroupsTooltip) ) @@ -390,11 +400,14 @@ fun ProStatItem( title: String, @DrawableRes icon: Int, disabled: Boolean = false, + loading: Boolean = false, tooltip: String? = null, ){ val scope = rememberCoroutineScope() val tooltipState = rememberTooltipState(isPersistent = true) + val disabledState = disabled && !loading + Row( modifier = modifier.then( // make the component clickable is there is an edit action @@ -412,23 +425,27 @@ fun ProStatItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ){ - Image( - painter = painterResource(id = icon), - contentDescription = null, - modifier = Modifier.size(32.dp), - colorFilter = ColorFilter.tint( - if(disabled) LocalColors.current.textSecondary else LocalColors.current.accent + if(loading){ + SmallCircularProgressIndicator() + } else { + Image( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(32.dp), + colorFilter = ColorFilter.tint( + if (disabledState) LocalColors.current.textSecondary else LocalColors.current.accent + ) ) - ) + } Text( modifier = Modifier.weight(1f), text = title, style = LocalType.current.h9, - color = if(disabled) LocalColors.current.textSecondary else LocalColors.current.text + color = if(disabledState) LocalColors.current.textSecondary else LocalColors.current.text ) - if(tooltip != null){ + if(tooltip != null && !loading){ SpeechBubbleTooltip( text = tooltip, tooltipState = tooltipState diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index fea5726655..feeee23998 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -214,7 +214,15 @@ class ProSettingsViewModel @Inject constructor( subscriptionExpiryDate = when(subType){ is SubscriptionType.Active -> subType.duration.expiryFromNow() else -> "" - } + }, + proStats = State.Success( //todo PRO calculate properly + ProStats( + groupsUpdated = 0, + pinnedConversations = 12, + proBadges = 6400, + longMessages = 215, + ) + ) ) } } @@ -501,7 +509,7 @@ class ProSettingsViewModel @Inject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), - val proStats: ProStats = ProStats(), + val proStats: State = State.Loading, val hasBillingCapacity: Boolean = false, // true is the current build flavour supports billing val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" From 9b40a76a5077c6ebd30be2469ac5ae0018cdfad6 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 13:18:31 +1100 Subject: [PATCH 023/219] SES-4643 Fixed qa tag string --- .../conversation/v2/messages/VisibleMessageView.kt | 8 +++++--- .../thoughtcrime/securesms/preferences/SettingsScreen.kt | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 4162074c0e..74f1f73ff2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -341,9 +341,11 @@ class VisibleMessageView : FrameLayout { // Set text & icons as appropriate for the message state. Note: Possible message states we care // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent. - messageStatus.messageText?.let{ + messageStatus.messageTextRes?.let{ binding.messageStatusTextView.setText(it) - binding.messageStatusTextView.contentDescription = context.getString(R.string.AccessibilityId_send_status) + it + binding.messageStatusTextView.contentDescription = + context.getString(R.string.AccessibilityId_send_status)+ + context.getString(it) } messageStatus.iconTint?.let(binding.messageStatusTextView::setTextColor) messageStatus.iconId?.let { ContextCompat.getDrawable(context, it) } @@ -421,7 +423,7 @@ class VisibleMessageView : FrameLayout { data class MessageStatusInfo(@DrawableRes val iconId: Int?, @ColorInt val iconTint: Int?, - @StringRes val messageText: Int?) + @StringRes val messageTextRes: Int?) private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo? = when { message.isFailed -> 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 4c05297cfd..7e0154465b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -270,7 +270,7 @@ fun Settings( ProBadge( modifier = Modifier.padding(start = 4.dp) .qaTag(stringResource(R.string.qa_pro_badge_icon)), - colors = if(uiState.subscriptionState is SubscriptionType.Active) + colors = if(uiState.subscriptionState.type is SubscriptionType.Active) proBadgeColorStandard() else proBadgeColorDisabled() ) From a9258f4f374e91db51f3fb8e715cc4d1ce27d278 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 15:57:18 +1100 Subject: [PATCH 024/219] UI and logic updates from the PRD --- .../securesms/onboarding/landing/Landing.kt | 29 ++------ .../onboarding/landing/LandingActivity.kt | 4 +- .../prosettings/ProSettingsDialogs.kt | 67 ++---------------- .../prosettings/ProSettingsHomeScreen.kt | 9 +++ .../prosettings/ProSettingsViewModel.kt | 25 +++++++ .../securesms/pro/ProStatusManager.kt | 2 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 69 +++++++++++++++++++ .../thoughtcrime/securesms/util/DateUtils.kt | 40 ++++++++--- 8 files changed, 145 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt index d66338ca3f..5362632d4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/Landing.kt @@ -39,12 +39,10 @@ import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.thoughtcrime.securesms.ui.AlertDialog -import org.thoughtcrime.securesms.ui.DialogButtonData -import org.thoughtcrime.securesms.ui.GetString -import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton +import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.AccentFillButton import org.thoughtcrime.securesms.ui.components.AccentOutlineButton +import org.thoughtcrime.securesms.ui.components.BorderlessHtmlButton import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -60,7 +58,7 @@ private fun PreviewLandingScreen( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - LandingScreen({}, {}, {}, {}) + LandingScreen({}, {}) } } @@ -68,8 +66,6 @@ private fun PreviewLandingScreen( internal fun LandingScreen( createAccount: () -> Unit, loadAccount: () -> Unit, - openTerms: () -> Unit, - openPrivacyPolicy: () -> Unit, ) { var count by remember { mutableStateOf(0) } val listState = rememberLazyListState() @@ -77,21 +73,10 @@ internal fun LandingScreen( var isUrlDialogVisible by remember { mutableStateOf(false) } if (isUrlDialogVisible) { - AlertDialog( - onDismissRequest = { isUrlDialogVisible = false }, - title = stringResource(R.string.urlOpen), - text = stringResource(R.string.urlOpenBrowser), - showCloseButton = true, // display the 'x' button - buttons = listOf( - DialogButtonData( - text = GetString(R.string.onboardingTos), - onClick = openTerms - ), - DialogButtonData( - text = GetString(R.string.onboardingPrivacy), - onClick = openPrivacyPolicy - ) - ) + TCPolicyDialog( + tcsUrl = "https://getsession.org/terms-of-service", + privacyUrl = "https://getsession.org/privacy-policy", + onDismissRequest = { isUrlDialogVisible = false }, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt index 3be3eafcc2..dcee8a0da5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/landing/LandingActivity.kt @@ -33,9 +33,7 @@ class LandingActivity: BaseActionBarActivity() { setComposeContent { LandingScreen( createAccount = { startPickDisplayNameActivity() }, - loadAccount = { start() }, - openTerms = { open("https://getsession.org/terms-of-service") }, - openPrivacyPolicy = { open("https://getsession.org/privacy-policy") } + loadAccount = { start() } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt index 576e0e4870..d790d1e945 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsDialogs.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.ui.Divider import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.IconActionRowItem import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.TCPolicyDialog import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -51,7 +52,9 @@ fun ProSettingsDialogs( // T&C + Policy dialog if(dialogsState.showTCPolicyDialog){ TCPolicyDialog( - sendCommand = sendCommand + tcsUrl = "https://getsession.org/pro/terms", + privacyUrl = "https://getsession.org/pro/privacy", + onDismissRequest = { sendCommand(HideTCPolicyDialog) }, ) } @@ -90,66 +93,4 @@ fun ProSettingsDialogs( buttons = buttons ) } -} - -@Composable -fun TCPolicyDialog( - sendCommand: (ProSettingsViewModel.Commands) -> Unit -){ - AlertDialog( - onDismissRequest = { sendCommand(HideTCPolicyDialog) }, - title = stringResource(R.string.urlOpen), - text = stringResource(R.string.urlOpenBrowser), - content = { - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - Cell( - bgColor = LocalColors.current.backgroundTertiary - ) { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - val context = LocalContext.current - val spacing = LocalDimensions.current.xsSpacing - - val tcsUrl = "https://getsession.org/pro/terms" - IconActionRowItem( - title = annotatedStringResource(tcsUrl), - textStyle = LocalType.current.large.bold(), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconSmall, - paddingValues = PaddingValues(start = spacing), - qaTag = R.string.AccessibilityId_onboardingTos, - onClick = { - context.openUrl(tcsUrl) - } - ) - Divider(paddingValues = PaddingValues(horizontal = spacing)) - val privacyUrl = "https://getsession.org/pro/privacy" - IconActionRowItem( - title = annotatedStringResource(privacyUrl), - textStyle = LocalType.current.large.bold(), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconSmall, - paddingValues = PaddingValues(start = spacing), - qaTag = R.string.AccessibilityId_onboardingPrivacy, - onClick = { - context.openUrl(privacyUrl) - } - ) - } - } - } - ) -} - -@Preview -@Composable -private fun PreviewCPolicyDialog( - @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors -) { - PreviewTheme(colors) { - TCPolicyDialog( - sendCommand = {} - ) - } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 66027c2bcc..5def42cdda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -318,6 +318,15 @@ fun ProStats( // Cell content Column( modifier = Modifier.fillMaxWidth() + .then( + // make the component clickable is we are in the loading state + if (data !is State.Success) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(OnProStatsClicked) } + ) + else Modifier + ) .padding(LocalDimensions.current.smallSpacing), verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) ){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index feeee23998..3e3d40bb4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -455,6 +455,30 @@ class ProSettingsViewModel @Inject constructor( else -> {} } } + + Commands.OnProStatsClicked -> { + when(_proSettingsUIState.value.proStats){ + // if we are in a loading or refresh state we should show a dialog instead + is State.Loading -> { + _dialogState.update { + it.copy( + showSimpleDialog = SimpleDialogData( + title = Phrase.from(context.getText(R.string.proStatsLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + message = Phrase.from(context.getText(R.string.proStatsLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format(), + positiveText = context.getString(R.string.okay), + positiveStyleDanger = false, + ) + ) + } + } + + else -> {} + } + } } } @@ -505,6 +529,7 @@ class ProSettingsViewModel @Inject constructor( data object ConfirmProPlan: Commands data object OnHeaderClicked: Commands + data object OnProStatsClicked: Commands } data class ProSettingsState( 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 2a9928c22b..1dadf2f624 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -135,7 +135,7 @@ class ProStatusManager @Inject constructor( ) }, // SubscriptionType.NeverSubscribed, - refreshState = State.Error(Exception()), + refreshState = State.Success(Unit), ) }.stateIn(GlobalScope, SharingStarted.Eagerly, diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index 3e7df9f5fe..e0290112aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -36,6 +37,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase import network.loki.messenger.R @@ -48,6 +50,8 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold data class DialogButtonData( @@ -434,4 +438,69 @@ fun PreviewLoadingDialog() { title = stringResource(R.string.warning) ) } +} + +@Composable +fun TCPolicyDialog( + tcsUrl: String, + privacyUrl: String, + onDismissRequest: () -> Unit +){ + AlertDialog( + onDismissRequest = onDismissRequest, + title = stringResource(R.string.urlOpen), + text = stringResource(R.string.urlOpenBrowser), + content = { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + Cell( + bgColor = LocalColors.current.backgroundTertiary + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + val context = LocalContext.current + val spacing = LocalDimensions.current.xsSpacing + + IconActionRowItem( + title = annotatedStringResource(tcsUrl), + textStyle = LocalType.current.large.bold(), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconSmall, + paddingValues = PaddingValues(start = spacing), + qaTag = R.string.AccessibilityId_onboardingTos, + onClick = { + context.openUrl(tcsUrl) + } + ) + Divider(paddingValues = PaddingValues(horizontal = spacing)) + + IconActionRowItem( + title = annotatedStringResource(privacyUrl), + textStyle = LocalType.current.large.bold(), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconSmall, + paddingValues = PaddingValues(start = spacing), + qaTag = R.string.AccessibilityId_onboardingPrivacy, + onClick = { + context.openUrl(privacyUrl) + } + ) + } + } + } + ) +} + +@Preview +@Composable +private fun PreviewCPolicyDialog( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + TCPolicyDialog( + tcsUrl = "https://getsession.org/", + privacyUrl = "https://getsession.org/", + onDismissRequest = {} + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt index 4d2259e7a7..030560f41f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -200,25 +200,43 @@ class DateUtils @Inject constructor( } fun getExpiryString(instant: Instant?): String { + if (instant == null) return context.getString(R.string.proExpired) + val now = Instant.now() - val timeRemaining = Duration.between(now, instant) + val remaining = Duration.between(now, instant) - // Instant has passed - if (timeRemaining.isNegative || timeRemaining.isZero) { + // Already expired + if (remaining.isNegative || remaining.isZero) { return context.getString(R.string.proExpired) } - val totalHours = max(timeRemaining.toHours(), 1) val locale = context.resources.configuration.locales[0] val format = MeasureFormat.getInstance(locale, MeasureFormat.FormatWidth.WIDE) - return if (totalHours >= 24) { - // More than one full day remaining - show days - val days = timeRemaining.toDays() - format.format(Measure(days, MeasureUnit.DAY)) - } else { - // Less than 24 hours remaining - show hours - format.format(Measure(totalHours, MeasureUnit.HOUR)) + // Round any fractional second up to the next whole second + val totalSeconds = remaining.seconds + if (remaining.nano > 0) 1 else 0 + val DAY: Long = 86_400 + val HOUR: Long = 3_600 + val MIN: Long = 60 + + fun ceilDiv(n: Long, d: Long) = (n + d - 1) / d + + return when { + // "Days is used when there is more than 1 full day before expiry" + totalSeconds > DAY -> { + val days = ceilDiv(totalSeconds, DAY) + format.format(Measure(days, MeasureUnit.DAY)) + } + // Hours - using >= here makes exactly 1h show "1 hour" + totalSeconds >= HOUR -> { + val hours = ceilDiv(totalSeconds, HOUR) + format.format(Measure(hours, MeasureUnit.HOUR)) + } + else -> { + // Less than 1h → minutes, rounded up; ensure minimum of 1 minute + val minutes = max(1L, ceilDiv(totalSeconds, MIN)) + format.format(Measure(minutes, MeasureUnit.MINUTE)) + } } } From 8a4076be058235247c0740d7befdf22cc42d5b13 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 16:10:01 +1100 Subject: [PATCH 025/219] Clean up --- .../preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt | 1 - .../securesms/pro/subscription/NoOpSubscriptionManager.kt | 2 -- 2 files changed, 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 5fedf8c362..3be3ec2752 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -43,7 +43,6 @@ fun ChoosePlanNoBilling( //todo PRO cater for NEVER SUBSCRIBED here - //todo PRO currently teh same key are used so we can't display both in the same string - need to update crowdin val defaultGoogleStore = ProStatusManager.DEFAULT_GOOGLE_STORE val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 8e13291492..91a7c8877f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -23,6 +23,4 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override fun hasValidSubscription(productId: String): Boolean { return false } - - //todo PRO test out build type with no subscription providers available - What do we show on the Pro Settings page? } \ No newline at end of file From 56bed39d870f84bc2c9eaa07fc53bab0d2505791 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 6 Oct 2025 17:33:35 +1100 Subject: [PATCH 026/219] Specific text for expired loading and error states --- .../prosettings/ProSettingsHomeScreen.kt | 71 +++++++++++++- .../prosettings/ProSettingsViewModel.kt | 95 ++++++++++++++----- .../securesms/pro/ProStatusManager.kt | 2 +- 3 files changed, 139 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 5def42cdda..90a46498a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -135,7 +135,12 @@ fun ProSettingsHome( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) ) { Text( - text = Phrase.from(context.getText(R.string.proStatusLoadingSubtitle)) + text = Phrase.from(context.getText( + when(subscriptionType){ + is SubscriptionType.Active -> R.string.proStatusLoadingSubtitle + else -> R.string.checkingProStatus + //todo PRO will need to handle never subscribed here + })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), style = LocalType.current.base, @@ -153,7 +158,12 @@ fun ProSettingsHome( horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing) ) { Text( - text = Phrase.from(context.getText(R.string.proErrorRefreshingStatus)) + text = Phrase.from(context.getText( + when(subscriptionType){ + is SubscriptionType.Active -> R.string.proErrorRefreshingStatus + else -> R.string.errorCheckingProStatus + //todo PRO will need to handle never subscribed here + })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), style = LocalType.current.base, @@ -788,7 +798,8 @@ fun ProManage( val (subtitle, subColor, icon) = when(subscriptionRefreshState){ is State.Loading -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.proPlanLoadingEllipsis) + //todo PRO need the ellipsis version of this string + Phrase.from(LocalContext.current, R.string.checkingProStatus) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.text, @@ -796,7 +807,7 @@ fun ProManage( ) is State.Error -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.errorLoadingProPlan) + Phrase.from(LocalContext.current, R.string.errorCheckingProStatus) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.warning, renewIcon @@ -973,6 +984,58 @@ fun PreviewProSettingsExpired( } } +@Preview +@Composable +fun PreviewProSettingsExpiredLoading( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired(SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Loading, + ) + ), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredError( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired(SubscriptionDetails( + device = "iPhone", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Error(Exception()), + ) + ), + sendCommand = {}, + onBack = {}, + ) + } +} + @Preview @Composable fun PreviewProSettingsNonPro( diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 3e3d40bb4f..3791ee6bec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -240,15 +240,27 @@ class ProSettingsViewModel @Inject constructor( when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { + val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proPlanLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proPlanLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusRenew)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + //todo PRO will need to handle never subscribed here + } + _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proPlanLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proPlanLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.okay), positiveStyleDanger = false, ) @@ -257,15 +269,27 @@ class ProSettingsViewModel @Inject constructor( } is State.Error -> { + val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proPlanError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proPlanNetworkLoadError)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + else -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRenewError)) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .format() + //todo PRO will need to handle never subscribed here + } + _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proPlanError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proPlanNetworkLoadError)) - .put(APP_NAME_KEY, context.getString(R.string.app_name)) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.retry), negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, @@ -413,15 +437,26 @@ class ProSettingsViewModel @Inject constructor( when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { + val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusLoadingDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + //todo PRO will need to handle never subscribed here + } _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.okay), positiveStyleDanger = false, ) @@ -431,14 +466,26 @@ class ProSettingsViewModel @Inject constructor( is State.Error -> { _dialogState.update { + val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + //todo PRO will need to handle never subscribed here + } + it.copy( showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + title = title, + message = message, positiveText = context.getString(R.string.retry), negativeText = context.getString(R.string.helpSupport), positiveStyleDanger = false, 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 1dadf2f624..2a9928c22b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -135,7 +135,7 @@ class ProStatusManager @Inject constructor( ) }, // SubscriptionType.NeverSubscribed, - refreshState = State.Success(Unit), + refreshState = State.Error(Exception()), ) }.stateIn(GlobalScope, SharingStarted.Eagerly, From a988ec78ef0c8d92b3936e9546edd80d09ae5f00 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 7 Oct 2025 12:11:17 +1100 Subject: [PATCH 027/219] Simplified Pro CTA logic --- .../settings/ConversationSettingsDialogs.kt | 10 +- .../securesms/preferences/SettingsScreen.kt | 16 +- .../securesms/ui/ProComponents.kt | 219 +++++++----------- 3 files changed, 102 insertions(+), 143 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index f752697802..41f8a0fe57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -37,12 +37,13 @@ import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsV import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupName import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.CTAImage import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GenericProCTA import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.PinProCTA import org.thoughtcrime.securesms.ui.RadioOption -import org.thoughtcrime.securesms.ui.SimpleSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.SessionProCTA import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon import org.thoughtcrime.securesms.ui.components.DialogTitledRadioButton import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField @@ -248,9 +249,9 @@ fun ConversationSettingsDialogs( } is ConversationSettingsViewModel.ProBadgeCTA.Group -> { - SimpleSessionProActivatedCTA( - heroImage = R.drawable.cta_hero_group, + SessionProCTA( title = stringResource(R.string.proGroupActivated), + badgeAtStart = true, textContent = { AnnotatedTextWithIcon( modifier = Modifier @@ -261,6 +262,9 @@ fun ConversationSettingsDialogs( style = LocalType.current.large, ) }, + content = { CTAImage(heroImage = R.drawable.cta_hero_group) }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = { sendCommand(HideProBadgeCTA) } 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 7e0154465b..62cbb09cbc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -88,7 +88,7 @@ import org.thoughtcrime.securesms.tokenpage.TokenPageActivity import org.thoughtcrime.securesms.ui.AccountIdHeader import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.AnimatedProfilePicProCTA -import org.thoughtcrime.securesms.ui.AnimatedSessionProActivatedCTA +import org.thoughtcrime.securesms.ui.CTAAnimatedImages import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.Divider @@ -100,6 +100,7 @@ import org.thoughtcrime.securesms.ui.PathDot import org.thoughtcrime.securesms.ui.ProBadge import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.SessionProCTA import org.thoughtcrime.securesms.ui.components.AcccentOutlineCopyButton import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.AnnotatedTextWithIcon @@ -1005,10 +1006,9 @@ fun AnimatedProCTA( sendCommand: (SettingsViewModel.Commands) -> Unit, ){ if(isPro) { - AnimatedSessionProActivatedCTA ( - heroImageBg = R.drawable.cta_hero_animated_bg, - heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + SessionProCTA ( title = stringResource(R.string.proActivated), + badgeAtStart = true, textContent = { ProBadgeText( modifier = Modifier.align(Alignment.CenterHorizontally), @@ -1028,6 +1028,14 @@ fun AnimatedProCTA( ) ) }, + content = { + CTAAnimatedImages( + heroImageBg = R.drawable.cta_hero_animated_bg, + heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, + ) + }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = { sendCommand(HideAnimatedProCTA) } ) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 640158f89d..33eae86709 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -207,11 +208,15 @@ private fun PreviewProBadgeText( @Composable fun SessionProCTA( content: @Composable () -> Unit, - text: String, - features: List, + textContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + badgeAtStart: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: () -> Unit = {}, + onCancel: () -> Unit = {}, ){ BasicAlertDialog( modifier = modifier, @@ -237,49 +242,61 @@ fun SessionProCTA( // title ProBadgeText( modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.upgradeTo) + text = title, + badgeAtStart = badgeAtStart ) Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) // main message - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = text, - textAlign = TextAlign.Center, - style = LocalType.current.base.copy( - color = LocalColors.current.textSecondary - ) - ) + textContent() Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) // features - features.forEachIndexed { index, feature -> - ProCTAFeature(data = feature) - if(index < features.size - 1){ - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + if(features.isNotEmpty()) { + features.forEachIndexed { index, feature -> + ProCTAFeature(data = feature) + if (index < features.size - 1) { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + } } - } - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + } // buttons Row( - Modifier.height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing), + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + LocalDimensions.current.xsSpacing, + Alignment.CenterHorizontally + ), ) { - AccentFillButtonRect( - modifier = Modifier.weight(1f).shimmerOverlay(), - text = stringResource(R.string.theContinue), - onClick = onUpgrade - ) + positiveButtonText?.let { + AccentFillButtonRect( + modifier = Modifier.then( + if(negativeButtonText != null) + Modifier.weight(1f) + else Modifier + ).shimmerOverlay(), + text = it, + onClick = onUpgrade + ) + } - TertiaryFillButtonRect( - modifier = Modifier.weight(1f), - text = stringResource(R.string.cancel), - onClick = onCancel - ) + negativeButtonText?.let { + TertiaryFillButtonRect( + modifier = Modifier.then( + if(positiveButtonText != null) + Modifier.weight(1f) + else Modifier + ), + text = it, + onClick = onCancel + ) + } } } } @@ -306,15 +323,32 @@ sealed interface CTAFeature { fun SimpleSessionProCTA( @DrawableRes heroImage: Int, text: String, - features: List, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + badgeAtStart: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: () -> Unit = {}, + onCancel: () -> Unit = {}, ){ SessionProCTA( modifier = modifier, - text = text, + title = title, + badgeAtStart = badgeAtStart, + textContent = { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = text, + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, features = features, + positiveButtonText = positiveButtonText, + negativeButtonText = negativeButtonText, onUpgrade = onUpgrade, onCancel = onCancel, content = { CTAImage(heroImage = heroImage) } @@ -348,7 +382,16 @@ fun AnimatedSessionProCTA( ){ SessionProCTA( modifier = modifier, - text = text, + textContent = { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = text, + textAlign = TextAlign.Center, + style = LocalType.current.base.copy( + color = LocalColors.current.textSecondary + ) + ) + }, features = features, onUpgrade = onUpgrade, onCancel = onCancel, @@ -396,104 +439,6 @@ fun CTAAnimatedImages( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SessionProActivatedCTA( - imageContent: @Composable () -> Unit, - modifier: Modifier = Modifier, - title: String, - textContent: @Composable ColumnScope.() -> Unit, - onCancel: () -> Unit, -){ - BasicAlertDialog( - modifier = modifier, - onDismissRequest = onCancel, - content = { - DialogBg { - Column(modifier = Modifier.fillMaxWidth()) { - // hero image - BottomFadingEdgeBox( - fadingEdgeHeight = 70.dp, - fadingColor = LocalColors.current.backgroundSecondary, - content = { _ -> - imageContent() - }, - ) - - // content - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing) - ) { - // title - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = title, - textStyle = LocalType.current.h5, - badgeAtStart = true - ) - - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // already have pro - textContent() - - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - - // buttons - TertiaryFillButtonRect( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = stringResource(R.string.close), - onClick = onCancel - ) - } - } - } - } - ) -} - -@Composable -fun SimpleSessionProActivatedCTA( - @DrawableRes heroImage: Int, - title: String, - onCancel: () -> Unit, - modifier: Modifier = Modifier, - textContent: @Composable ColumnScope.() -> Unit -){ - SessionProActivatedCTA( - modifier = modifier, - title = title, - textContent = textContent, - onCancel = onCancel, - imageContent = { CTAImage(heroImage = heroImage) } - ) -} - -@OptIn(ExperimentalGlideComposeApi::class) -@Composable -fun AnimatedSessionProActivatedCTA( - @DrawableRes heroImageBg: Int, - @DrawableRes heroImageAnimatedFg: Int, - title: String, - onCancel: () -> Unit, - modifier: Modifier = Modifier, - textContent: @Composable ColumnScope.() -> Unit -){ - SessionProActivatedCTA( - modifier = modifier, - title = title, - textContent = textContent, - onCancel = onCancel, - imageContent = { - CTAAnimatedImages( - heroImageBg = heroImageBg, - heroImageAnimatedFg = heroImageAnimatedFg - ) - }) -} - // Reusable generic Pro CTA @Composable fun GenericProCTA( @@ -643,8 +588,7 @@ private fun PreviewProActivatedCTA( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { - SimpleSessionProActivatedCTA( - heroImage = R.drawable.cta_hero_char_limit, + SessionProCTA( title = stringResource(R.string.proActivated), textContent = { ProBadgeText( @@ -665,6 +609,9 @@ private fun PreviewProActivatedCTA( ) ) }, + content = { CTAImage(heroImage = R.drawable.cta_hero_char_limit) }, + positiveButtonText = null, + negativeButtonText = stringResource(R.string.close), onCancel = {} ) } From 9de8f2f033bcbc0e5b89bec010cb89733fdb26db Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Tue, 7 Oct 2025 03:13:05 +0000 Subject: [PATCH 028/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 1 - app/src/main/res/values-b+cs+CZ/strings.xml | 1 - app/src/main/res/values/strings.xml | 18 +++++++++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index bed5c01c63..36762fff08 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -456,7 +456,6 @@ Əladır {emoji} {app_name} tətbiqini bir müddətdir istifadə edirsiniz, necə gedir? Fikirlərinizi eşitmək bizim üçün çox dəyərli olardı. Daxil ol - {pro} statusunu yoxlama xətası. Lütfən internet bağlantınızı yoxlayıb yenidən sınayın. Xətanı kopyala və çıx Veri bazası xətası diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index c289414389..0070c83ddd 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -476,7 +476,6 @@ Skvělé {emoji} Používáte {app_name} \na rádi bychom znali váš názor. Vstoupit - Chyba kontroly stavu {pro}. Zkontrolujte prosím připojení k internetu a zkuste to znovu. Zkopírovat chybu a ukončit Chyba databáze diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03d4dd34b8..45063dc1a6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -192,6 +192,10 @@ Enables voice and video calls to and from other users. You called {name} You missed a call from {name} because you haven\'t enabled Voice and Video Calls in Privacy Settings. + {app_name} needs access to your camera to enable video calls, but this permission has been denied. You can’t update your camera permissions during a call.\n\nWould you like to end the call now and enable camera access, or would you like to be reminded after the call? + To allow camera access, open settings and turn on the Camera permission. + During your last call, you tried to use video but couldn’t because camera access was previously denied. To allow camera access, open settings and turn on the Camera permission. + Camera Access Required No camera found Camera unavailable. Grant Camera Access @@ -208,6 +212,7 @@ Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. Checking {pro} Status Checking your {pro} details. Some information on this page may be unavailable until this check is complete. + Checking {pro} Status... Checking your {pro} details. You cannot renew until this check is complete. Checking your {pro} status. You\'ll be able to upgrade to {pro} once this check is complete. Clear @@ -341,6 +346,14 @@ Please wait while the group is created... Failed to Update Group You don’t have permission to delete others’ messages + + Delete Selected Attachment + Delete Selected Attachments + + + Are you sure you want to delete the selected attachment? The message associated with the attachment will also be deleted. + Are you sure you want to delete the selected attachments? The message associated with the attachments will also be deleted. + Are you sure you want to delete {name} from your contacts?\n\nThis will delete your conversation, including all messages and attachments. Future messages from {name} will appear as a message request. Are you sure you want to delete your conversation with {name}?\nThis will permanently delete all messages and attachments. @@ -456,7 +469,9 @@ You and {name} reacted with {emoji_name} Reacted to your message {emoji} Enable + Enable Camera Access? Show notifications when you receive new messages. + End Call to Enable Enjoying {app_name}? Needs Work {emoji} It\'s Great {emoji} @@ -465,7 +480,7 @@ Enter the password you set for {app_name} Enter the password you use to unlock Session &#13; on startup, not your Recovery Password - Error checking {pro} status. + Error checking {pro} status Please check your internet connection and try again. Copy Error and Quit Database Error @@ -830,6 +845,7 @@ on startup, not your Recovery Password Open Open {platform_store} Website Open {platform} Website + Open Settings Open Survey Other Password From 2ca5a346d120e057b12558553b210dad2e644460 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 7 Oct 2025 14:19:55 +1100 Subject: [PATCH 029/219] Pro Settings CTA --- .../utilities/TextSecurePreferences.kt | 24 ++++++ .../securesms/home/HomeDialogs.kt | 78 +++++++++++++++++++ .../securesms/home/HomeViewModel.kt | 18 ++++- .../securesms/ui/ProComponents.kt | 36 +++++++-- 4 files changed, 148 insertions(+), 8 deletions(-) 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 6db04761b8..e39d780823 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -28,6 +28,8 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.ENVIRONM import org.session.libsession.utilities.TextSecurePreferences.Companion.FOLLOW_SYSTEM_SETTINGS import org.session.libsession.utilities.TextSecurePreferences.Companion.FORCED_SHORT_TTL import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_HIDDEN_MESSAGE_REQUESTS +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN_PRO_EXPIRED +import org.session.libsession.utilities.TextSecurePreferences.Companion.HAS_SEEN_PRO_EXPIRING import org.session.libsession.utilities.TextSecurePreferences.Companion.HAVE_SHOWN_A_NOTIFICATION_ABOUT_TOKEN_PAGE import org.session.libsession.utilities.TextSecurePreferences.Companion.HIDE_PASSWORD import org.session.libsession.utilities.TextSecurePreferences.Companion.LAST_VACUUM_TIME @@ -174,6 +176,10 @@ interface TextSecurePreferences { fun setForceIncomingMessagesAsPro(isPro: Boolean) fun forcePostPro(): Boolean fun setForcePostPro(postPro: Boolean) + fun hasSeenProExpiring(): Boolean + fun setHasSeenProExpiring() + fun hasSeenProExpired(): Boolean + fun setHasSeenProExpired() fun watchPostProStatus(): StateFlow fun setShownCallWarning(): Boolean fun setShownCallNotification(): Boolean @@ -306,6 +312,8 @@ interface TextSecurePreferences { 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 CALL_NOTIFICATIONS_ENABLED = "pref_call_notifications_enabled" const val SHOWN_CALL_WARNING = "pref_shown_call_warning" // call warning is user-facing warning of enabling calls const val SHOWN_CALL_NOTIFICATION = "pref_shown_call_notification" // call notification is a prompt to check privacy settings @@ -1558,6 +1566,22 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(SET_FORCE_POST_PRO) } + override fun hasSeenProExpiring(): Boolean { + return getBooleanPreference(HAS_SEEN_PRO_EXPIRING, false) + } + + override fun setHasSeenProExpiring() { + setBooleanPreference(HAS_SEEN_PRO_EXPIRING, true) + } + + override fun hasSeenProExpired(): Boolean { + return getBooleanPreference(HAS_SEEN_PRO_EXPIRED, false) + } + + override fun setHasSeenProExpired() { + setBooleanPreference(HAS_SEEN_PRO_EXPIRED, true) + } + override fun watchPostProStatus(): StateFlow { return postProLaunchState } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 27c8f4655d..46d3822b0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -1,14 +1,31 @@ package org.thoughtcrime.securesms.home +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.squareup.phrase.Phrase +import network.loki.messenger.R +import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.NonTranslatableStringConstants.PRO +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet +import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA +import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.PinProCTA import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.UserProfileModal +import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable @@ -49,5 +66,66 @@ fun HomeDialogs( } ) } + + if(dialogsState.proExpiringCTA != null){ + val context = LocalContext.current + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_generic_bg, + heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, + title = stringResource(R.string.proExpiringSoon), + badgeAtStart = true, + text = Phrase.from(context,R.string.proExpiringSoonDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(TIME_KEY, dialogsState.proExpiringCTA.expiry) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + ), + positiveButtonText = stringResource(R.string.updatePlan), + negativeButtonText = stringResource(R.string.close), + onUpgrade = { + + //todo PRO go to screen once it exists + }, + onCancel = { + sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) + } + ) + } + + if(dialogsState.proExpiredCTA){ + val context = LocalContext.current + AnimatedSessionProCTA( + heroImageBg = R.drawable.cta_hero_generic_bg, + heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, + title = stringResource(R.string.proExpired), + badgeAtStart = true, + disabled = true, + text = Phrase.from(context,R.string.proExpiredDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString(), + features = listOf( + CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + ), + positiveButtonText = stringResource(R.string.renew), + negativeButtonText = stringResource(R.string.cancel), + onUpgrade = { + + //todo PRO go to screen once it exists + }, + onCancel = { + sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) + } + ) + } } } + diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 770b83ad74..993c9f333f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -241,6 +241,14 @@ class HomeViewModel @Inject constructor( is Commands.HideStartConversationSheet -> { _dialogsState.update { it.copy(showStartConversationSheet = null) } } + + is Commands.HideExpiringCTADialog -> { + _dialogsState.update { it.copy(proExpiringCTA = null) } + } + + is Commands.HideExpiredCTADialog -> { + _dialogsState.update { it.copy(proExpiredCTA = false) } + } } } @@ -264,19 +272,27 @@ class HomeViewModel @Inject constructor( data class DialogsState( val pinCTA: PinProCTA? = null, val userProfileModal: UserProfileModalData? = null, - val showStartConversationSheet: StartConversationSheetData? = null + val showStartConversationSheet: StartConversationSheetData? = null, + val proExpiringCTA: ProExpiringCTA? = null, + val proExpiredCTA: Boolean = false ) data class PinProCTA( val overTheLimit: Boolean ) + data class ProExpiringCTA( + val expiry: String + ) + data class StartConversationSheetData( val accountId: String ) sealed interface Commands { data object HidePinCTADialog : Commands + data object HideExpiringCTADialog : Commands + data object HideExpiredCTADialog : Commands data object HideUserProfileModal : Commands data class HandleUserProfileCommand( val upmCommand: UserProfileModalCommands diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 33eae86709..c19697a5d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged @@ -212,6 +213,7 @@ fun SessionProCTA( modifier: Modifier = Modifier, title: String = stringResource(R.string.upgradeTo), badgeAtStart: Boolean = false, + disabled: Boolean = false, features: List = emptyList(), positiveButtonText: String? = stringResource(R.string.theContinue), negativeButtonText: String? = stringResource(R.string.cancel), @@ -243,7 +245,8 @@ fun SessionProCTA( ProBadgeText( modifier = Modifier.align(Alignment.CenterHorizontally), text = title, - badgeAtStart = badgeAtStart + badgeAtStart = badgeAtStart, + badgeColors = if(disabled) proBadgeColorDisabled() else proBadgeColorStandard(), ) Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) @@ -375,10 +378,15 @@ fun AnimatedSessionProCTA( @DrawableRes heroImageBg: Int, @DrawableRes heroImageAnimatedFg: Int, text: String, - features: List, modifier: Modifier = Modifier, - onUpgrade: () -> Unit, - onCancel: () -> Unit, + title: String = stringResource(R.string.upgradeTo), + badgeAtStart: Boolean = false, + disabled: Boolean = false, + features: List = emptyList(), + positiveButtonText: String? = stringResource(R.string.theContinue), + negativeButtonText: String? = stringResource(R.string.cancel), + onUpgrade: () -> Unit = {}, + onCancel: () -> Unit = {}, ){ SessionProCTA( modifier = modifier, @@ -398,9 +406,16 @@ fun AnimatedSessionProCTA( content = { CTAAnimatedImages( heroImageBg = heroImageBg, - heroImageAnimatedFg = heroImageAnimatedFg + heroImageAnimatedFg = heroImageAnimatedFg, + disabled = disabled ) - }) + }, + positiveButtonText = positiveButtonText, + negativeButtonText = negativeButtonText, + title = title, + badgeAtStart = badgeAtStart, + disabled = disabled + ) } @OptIn(ExperimentalGlideComposeApi::class) @@ -408,11 +423,15 @@ fun AnimatedSessionProCTA( fun CTAAnimatedImages( @DrawableRes heroImageBg: Int, @DrawableRes heroImageAnimatedFg: Int, + disabled: Boolean = false ){ Image( modifier = Modifier .fillMaxWidth() - .background(LocalColors.current.accent), + .background( + if(disabled) LocalColors.current.disabled + else LocalColors.current.accent + ), contentScale = ContentScale.FillWidth, painter = painterResource(id = heroImageBg), contentDescription = null, @@ -430,6 +449,9 @@ fun CTAAnimatedImages( modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth, painter = painter, + colorFilter = if(disabled) + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + else null, contentDescription = null, ) } From d183c487d87799856879b15e8e3dd1fa327f4333 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 7 Oct 2025 15:56:28 +1100 Subject: [PATCH 030/219] Logic for the Pro Seettings CTA - no display logic yet --- .../securesms/home/HomeActivity.kt | 19 +++++++++++ .../securesms/home/HomeDialogs.kt | 9 +++--- .../securesms/home/HomeViewModel.kt | 22 +++++++++++++ .../prosettings/ProSettingsActivity.kt | 20 ++++++++++++ .../prosettings/ProSettingsNavHost.kt | 32 +++++++++++++++---- 5 files changed, 91 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 9e18662ea9..3318d50bf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -78,6 +78,7 @@ import org.thoughtcrime.securesms.home.startconversation.StartConversationDestin import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.preferences.SettingsActivity +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsActivity import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity import org.thoughtcrime.securesms.reviews.StoreReviewManager @@ -85,6 +86,7 @@ import org.thoughtcrime.securesms.reviews.ui.InAppReview import org.thoughtcrime.securesms.reviews.ui.InAppReviewViewModel import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.setThemedContent @@ -239,6 +241,23 @@ class HomeActivity : ScreenLockActionBarActivity(), } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + homeViewModel.uiEvents.collect { event -> + when (event) { + is HomeViewModel.UiEvent.OpenProSettings -> { + startActivity( + ProSettingsActivity.createIntent( + this@HomeActivity, + event.start + ) + ) + } + } + } + } + } + // Set up seed reminder view lifecycleScope.launchWhenStarted { binding.seedReminderView.setThemedContent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 46d3822b0d..c27193316c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.PinProCTA @@ -88,8 +89,8 @@ fun HomeDialogs( positiveButtonText = stringResource(R.string.updatePlan), negativeButtonText = stringResource(R.string.close), onUpgrade = { - - //todo PRO go to screen once it exists + sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.UpdatePlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) @@ -118,8 +119,8 @@ fun HomeDialogs( positiveButtonText = stringResource(R.string.renew), negativeButtonText = stringResource(R.string.cancel), onUpgrade = { - - //todo PRO go to screen once it exists + sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.GetOrRenewPlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 993c9f333f..3584e2cfda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -37,6 +38,7 @@ import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository @@ -82,6 +84,12 @@ class HomeViewModel @Inject constructor( private val _dialogsState = MutableStateFlow(DialogsState()) val dialogsState: StateFlow = _dialogsState + private val _uiEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + val uiEvents: SharedFlow = _uiEvents + /** * A [StateFlow] that emits the list of threads and the typing status of each thread. * @@ -249,6 +257,12 @@ class HomeViewModel @Inject constructor( is Commands.HideExpiredCTADialog -> { _dialogsState.update { it.copy(proExpiredCTA = false) } } + + is Commands.GotoProSettings -> { + viewModelScope.launch { + _uiEvents.emit(UiEvent.OpenProSettings(command.destination)) + } + } } } @@ -289,6 +303,10 @@ class HomeViewModel @Inject constructor( val accountId: String ) + sealed interface UiEvent { + data class OpenProSettings(val start: ProSettingsDestination) : UiEvent + } + sealed interface Commands { data object HidePinCTADialog : Commands data object HideExpiringCTADialog : Commands @@ -300,6 +318,10 @@ class HomeViewModel @Inject constructor( data object ShowStartConversationSheet : Commands data object HideStartConversationSheet : Commands + + data class GotoProSettings( + val destination: ProSettingsDestination + ): Commands } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index 0b3ca340a4..da7e5cbd20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.preferences.prosettings +import android.content.Context +import android.content.Intent import androidx.compose.runtime.Composable import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity @@ -12,10 +14,28 @@ class ProSettingsActivity: FullComposeScreenLockActivity() { @Inject lateinit var navigator: UINavigator + companion object { + private const val EXTRA_START_DESTINATION = "start_destination" + + fun createIntent( + context: Context, + startDestination: ProSettingsDestination = ProSettingsDestination.Home + ): Intent { + return Intent(context, ProSettingsActivity::class.java).apply { + putExtra(EXTRA_START_DESTINATION, startDestination) + } + } + } + @Composable override fun ComposeContent() { + val startDestination = intent.getParcelableExtra( + EXTRA_START_DESTINATION + ) ?: ProSettingsDestination.Home + ProSettingsNavHost( navigator = navigator, + startDestination = startDestination, onBack = this::finish ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 9e1785dbdd..efb35fe9f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.annotation.SuppressLint +import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable @@ -11,6 +12,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.GetOrRenewPlan @@ -26,22 +28,28 @@ import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.horizontalSlideComposable // Destinations -sealed interface ProSettingsDestination { +sealed interface ProSettingsDestination: Parcelable { @Serializable + @Parcelize data object Home: ProSettingsDestination @Serializable + @Parcelize data object UpdatePlan: ProSettingsDestination @Serializable + @Parcelize data object GetOrRenewPlan: ProSettingsDestination @Serializable + @Parcelize data object PlanConfirmation: ProSettingsDestination @Serializable + @Parcelize data object CancelSubscription: ProSettingsDestination @Serializable + @Parcelize data object RefundSubscription: ProSettingsDestination } @@ -50,6 +58,7 @@ sealed interface ProSettingsDestination { @Composable fun ProSettingsNavHost( navigator: UINavigator, + startDestination: ProSettingsDestination = Home, onBack: () -> Unit ){ SharedTransitionLayout { @@ -61,6 +70,15 @@ fun ProSettingsNavHost( val dialogsState by viewModel.dialogState.collectAsState() + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + scope.launch { navigator.navigateUp() } + } else { + onBack() // Finish activity if at root + } + } + + ObserveAsEvents(flow = navigator.navigationActions) { action -> when (action) { is NavigationAction.Navigate -> navController.navigate( @@ -79,7 +97,7 @@ fun ProSettingsNavHost( } } - NavHost(navController = navController, startDestination = Home) { + NavHost(navController = navController, startDestination = startDestination) { // Home horizontalSlideComposable { ProSettingsHomeScreen( @@ -92,13 +110,13 @@ fun ProSettingsNavHost( horizontalSlideComposable { UpdatePlanScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, + onBack = handleBack, ) } horizontalSlideComposable { GetOrRenewPlanScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, + onBack = handleBack, ) } @@ -106,7 +124,7 @@ fun ProSettingsNavHost( horizontalSlideComposable { PlanConfirmationScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = handleBack, ) } @@ -114,7 +132,7 @@ fun ProSettingsNavHost( horizontalSlideComposable { RefundPlanScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = handleBack, ) } @@ -122,7 +140,7 @@ fun ProSettingsNavHost( horizontalSlideComposable { CancelPlanScreen( viewModel = viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = handleBack, ) } } From a5b347f93324ad2dc10df1ce56c4286bbf526dea Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 7 Oct 2025 16:39:49 +1100 Subject: [PATCH 031/219] Display rules for PRO CTA --- .../securesms/home/HomeViewModel.kt | 52 +++++++++++++++++++ .../prosettings/ProSettingsHomeScreen.kt | 12 +++-- .../chooseplan/ChoosePlanNoBilling.kt | 4 ++ .../securesms/pro/ProStatusManager.kt | 2 + .../securesms/pro/SubscriptionType.kt | 2 + 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 3584e2cfda..4ecd116b9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -9,6 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -40,13 +41,18 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository +import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.UserProfileModalCommands import org.thoughtcrime.securesms.util.UserProfileModalData import org.thoughtcrime.securesms.util.UserProfileUtils import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject @HiltViewModel @@ -62,6 +68,7 @@ class HomeViewModel @Inject constructor( private val proStatusManager: ProStatusManager, private val upmFactory: UserProfileUtils.UserProfileUtilsFactory, private val recipientRepository: RecipientRepository, + private val dateUtils: DateUtils ) : ViewModel() { // SharedFlow that emits whenever the user asks us to reload the conversation private val manualReloadTrigger = MutableSharedFlow( @@ -150,6 +157,51 @@ class HomeViewModel @Inject constructor( private var userProfileModalJob: Job? = null private var userProfileModalUtils: UserProfileUtils? = null + init { + // observe subscription status + viewModelScope.launch { + proStatusManager.subscriptionState.collect { subscription -> + // show a CTA (only once per install) when + // - subscription is expiring in less than 7 days + // - subscription expired less than 30 days ago + val now = Instant.now() + + if(subscription.type is SubscriptionType.Active.Expiring + && !prefs.hasSeenProExpiring() + ){ + val validUntil = subscription.type.proStatus.validUntil ?: return@collect + + if (validUntil.isBefore(now.plus(7, ChronoUnit.DAYS))) { + delay(2000) + prefs.setHasSeenProExpiring() + _dialogsState.update { state -> + state.copy( + proExpiringCTA = ProExpiringCTA( + dateUtils.getExpiryString( + subscription.type.proStatus.validUntil + ) + ) + ) + } + } + } + else if(subscription.type is SubscriptionType.Expired + && !prefs.hasSeenProExpired()) { + val validUntil = subscription.type.expiredAt + + // Check if now is within 30 days after expiry + if (now.isBefore(validUntil.plus(30, ChronoUnit.DAYS))) { + delay(2000) + prefs.setHasSeenProExpired() + _dialogsState.update { state -> + state.copy(proExpiredCTA = true) + } + } + } + } + } + } + private fun observeTypingStatus(): Flow> = typingStatusRepository .typingThreads .asFlow() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 90a46498a0..f6777d27d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -967,7 +967,9 @@ fun PreviewProSettingsExpired( ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( subscriptionState = SubscriptionState( - type = SubscriptionType.Expired(SubscriptionDetails( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -993,7 +995,9 @@ fun PreviewProSettingsExpiredLoading( ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( subscriptionState = SubscriptionState( - type = SubscriptionType.Expired(SubscriptionDetails( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", @@ -1019,7 +1023,9 @@ fun PreviewProSettingsExpiredError( ProSettingsHome( data = ProSettingsViewModel.ProSettingsState( subscriptionState = SubscriptionState( - type = SubscriptionType.Expired(SubscriptionDetails( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( device = "iPhone", store = "Apple App Store", platform = "Apple", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 3be3ec2752..4b07cc406f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -31,6 +31,8 @@ import org.thoughtcrime.securesms.ui.components.iconExternalLink import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors +import java.time.Duration +import java.time.Instant @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable @@ -160,6 +162,7 @@ private fun PreviewNonOrigExpiredUpdatePlan( val context = LocalContext.current ChoosePlanNoBilling ( subscription = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( device = "iPhone", store = "Apple App Store", @@ -184,6 +187,7 @@ private fun PreviewNoBiilingBrandNewPlan( val context = LocalContext.current ChoosePlanNoBilling ( subscription = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( device = "iPhone", store = "Apple App Store", 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 2a9928c22b..10e3ae5646 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -114,6 +114,7 @@ class ProStatusManager @Inject constructor( ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED -> SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), subscriptionDetails = SubscriptionDetails( device = "Android", store = "Google Play Store", @@ -124,6 +125,7 @@ class ProStatusManager @Inject constructor( ) ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), subscriptionDetails = SubscriptionDetails( device = "iPhone", store = "Apple App Store", diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt index d950f6f875..376271445d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/SubscriptionType.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.pro import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.util.State +import java.time.Instant sealed interface SubscriptionType{ data object NeverSubscribed: SubscriptionType @@ -27,6 +28,7 @@ sealed interface SubscriptionType{ } data class Expired( + val expiredAt: Instant, val subscriptionDetails: SubscriptionDetails ): SubscriptionType } From f98674beab8e093aae6a0546d7ee5657edf610d5 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 8 Oct 2025 08:33:42 +1100 Subject: [PATCH 032/219] PR feedback --- .../prosettings/chooseplan/ChoosePlanNoBilling.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 4b07cc406f..b6b23271b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -91,7 +91,8 @@ fun ChoosePlanNoBilling( } val cells: List = buildList { - addAll(listOf( + // cell 1 + add( NonOriginatingLinkCellData( title = stringResource(R.string.onLinkedDevice), info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) @@ -101,7 +102,11 @@ fun ChoosePlanNoBilling( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format(), iconRes = R.drawable.ic_link - ), + ) + ) + + // cell 2 + add( NonOriginatingLinkCellData( title = stringResource(R.string.proNewInstallation), info = Phrase.from(context.getText(R.string.proNewInstallationDescription)) @@ -111,8 +116,9 @@ fun ChoosePlanNoBilling( .format(), iconRes = R.drawable.ic_smartphone ) - )) + ) + // optional cell 3 if(subscription is SubscriptionType.Expired) { add( NonOriginatingLinkCellData( From ea8710db0fe8876416dd2a11ee18edb7a279a0f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:24:37 +0000 Subject: [PATCH 033/219] Bump org.assertj:assertj-core from 3.27.4 to 3.27.6 (#1579) --- 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 7ceafa1fab..59d60fa8ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ accompanistPermissionsVersion = "0.37.3" activityKtxVersion = "1.10.1" androidImageCropperVersion = "4.6.0" androidVersion = "137.7151.04" -assertjCoreVersion = "3.27.4" +assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" cameraCamera2Version = "1.5.0" cardviewVersion = "1.0.0" From 94cdd95ec9301a3922e51d7f99763725ba07b3b0 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Wed, 8 Oct 2025 04:47:56 +0000 Subject: [PATCH 034/219] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45063dc1a6..527dc76da7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,6 +1119,7 @@ on startup, not your Recovery Password %1$d character remaining %1$d characters remaining + Remind Me Later Remove Failed to remove password Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device. From 17404c4c8cf7a1b5ada868974538a28786cd6089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:20:21 +0000 Subject: [PATCH 035/219] Bump androidx.navigation:navigation-compose from 2.9.4 to 2.9.5 (#1561) --- 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 59d60fa8ee..cfb83e7b6e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ legacySupportV13Version = "1.0.0" libsessionUtilAndroidVersion = "1.0.8-1-g27817b4" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" -navVersion = "2.9.4" +navVersion = "2.9.5" appcompatVersion = "1.7.1" coreVersion = "1.16.0" coroutinesVersion = "1.10.2" From ad51be0402dd94cf71cdb04b5c5333a0bf00055f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:29:28 +0000 Subject: [PATCH 036/219] Bump com.google.protobuf:protoc from 4.31.1 to 4.32.1 (#1581) --- 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 cfb83e7b6e..b0f913460e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -169,7 +169,7 @@ huawei-push = { module = 'com.huawei.hms:push', version.ref = 'huaweiPushVersion google-play-review = { module = "com.google.android.play:review", version.ref = "googlePlayReviewVersion" } google-play-review-ktx = { module = "com.google.android.play:review-ktx", version.ref = "googlePlayReviewVersion" } sqlite-web-viewer = { module = "io.github.simophin:sqlite-web-viewer", version = "0.0.3" } -protoc = { module = "com.google.protobuf:protoc", version = "4.31.1" } +protoc = { module = "com.google.protobuf:protoc", version = "4.32.1" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilVersion" } android-billing = { module = "com.android.billingclient:billing", version.ref = "billingVersion" } From c1140f49734c9861d5ce2412d845881c6459ff60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 23:39:46 +0000 Subject: [PATCH 037/219] Bump com.google.devtools.ksp from 2.2.10-2.0.2 to 2.2.20-2.0.3 (#1563) --- 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 b0f913460e..904e37918a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ googleServicesVersion = "4.4.3" junit = "1.3.0" kotlinVersion = "2.2.20" kryoVersion = "5.6.2" -kspVersion = "2.2.10-2.0.2" +kspVersion = "2.2.20-2.0.4" legacySupportV13Version = "1.0.0" libsessionUtilAndroidVersion = "1.0.8-1-g27817b4" media3ExoplayerVersion = "1.8.0" From 20bacce94cfb4ef0ba40d759c2cae2e79543e832 Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 13 Oct 2025 00:37:39 +0000 Subject: [PATCH 038/219] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 527dc76da7..4dc0a1c7d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -478,8 +478,7 @@ You\'ve been using {app_name} for a little while, how’s it going? We’d really appreciate hearing your thoughts. Enter Enter the password you set for {app_name} - Enter the password you use to unlock Session &#13; -on startup, not your Recovery Password + Enter the password you use to unlock {app_name} on startup, not your Recovery Password Error checking {pro} status Please check your internet connection and try again. Copy Error and Quit From 7b49aaed7926fad9e7a43e21d10088c98e5bc774 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:09:10 +0000 Subject: [PATCH 039/219] Bump androidx.test:orchestrator from 1.5.1 to 1.6.1 (#1603) --- 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 904e37918a..4f1e63f055 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ junitVersion = "4.13.2" kotlinxJsonVersion = "1.9.0" kovenantVersion = "3.3.0" opencsvVersion = "5.12.0" -orchestratorVersion = "1.5.1" +orchestratorVersion = "1.6.1" photoviewVersion = "2.3.0" phraseVersion = "1.2.0" lifecycleVersion = "2.9.4" From 479a281badaac14e2c4766a9e1a312eed056cadc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:20:00 +0000 Subject: [PATCH 040/219] Bump com.autonomousapps.dependency-analysis from 2.17.0 to 3.1.0 (#1601) --- 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 4f1e63f055..72ec55199e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ firebaseMessagingVersion = "25.0.1" flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" gradlePluginVersion = "8.13.0" -dependenciesAnalysisVersion = "2.17.0" +dependenciesAnalysisVersion = "3.1.0" googleServicesVersion = "4.4.3" junit = "1.3.0" kotlinVersion = "2.2.20" From 83fadf0559ac501c72e810611c090efeb99c675c Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 14 Oct 2025 09:37:58 +0800 Subject: [PATCH 041/219] SES-4665 : Remove screenshot notification (#1599) * remove notifying of screenshot * Remove extraction of screenshot notification * Revert "Remove extraction of screenshot notification" This reverts commit 7850cec8b4f04c2651a3117575c2bf5c0d67aa57. * Removed extraction and other support for screenshot * Remove unused function * Fixed screenshot message --- .../messages/signal/IncomingMediaMessage.java | 7 ------ .../ReceivedMessageHandler.kt | 1 - .../conversation/v2/ConversationActivityV2.kt | 23 ------------------- .../securesms/database/MmsDatabase.kt | 3 --- 4 files changed, 34 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java index 3e040232eb..f61bd72e5f 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java +++ b/app/src/main/java/org/session/libsession/messaging/messages/signal/IncomingMediaMessage.java @@ -136,13 +136,6 @@ public boolean hasMention() { return hasMention; } - public boolean isScreenshotDataExtraction() { - if (dataExtractionNotification == null) return false; - else { - return dataExtractionNotification.getKind() == DataExtractionNotificationInfoMessage.Kind.SCREENSHOT; - } - } - public boolean isMediaSavedDataExtraction() { if (dataExtractionNotification == null) return false; else { diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 9c33087c91..44ce02ebb8 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -214,7 +214,6 @@ class ReceivedMessageHandler @Inject constructor( val senderPublicKey = message.sender!! val notification: DataExtractionNotificationInfoMessage = when(message.kind) { - is DataExtractionNotification.Kind.Screenshot -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.SCREENSHOT) is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage(DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED) else -> return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index f8d946cc74..f4edf63b8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -119,7 +119,6 @@ import org.session.libsignal.utilities.hexEncodedPrivateKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.FullComposeActivity.Companion.applyCommonPropertiesForCompose import org.thoughtcrime.securesms.ScreenLockActionBarActivity -import org.thoughtcrime.securesms.attachments.ScreenshotObserver import org.thoughtcrime.securesms.audio.AudioRecorderHandle import org.thoughtcrime.securesms.audio.recordAudio import org.thoughtcrime.securesms.components.TypingStatusSender @@ -269,13 +268,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override val applyAutoScrimForNavigationBar: Boolean get() = false - private val screenshotObserver by lazy { - ScreenshotObserver(this, Handler(Looper.getMainLooper())) { - // post screenshot message - sendScreenshotNotification() - } - } - private val screenWidth = Resources.getSystem().displayMetrics.widthPixels private val linkPreviewViewModel: LinkPreviewViewModel by lazy { ViewModelProvider(this, LinkPreviewViewModel.Factory(LinkPreviewRepository())) @@ -733,18 +725,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, override fun onResume() { super.onResume() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(viewModel.threadId) - - contentResolver.registerContentObserver( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - true, - screenshotObserver - ) } override fun onPause() { super.onPause() ApplicationContext.getInstance(this).messageNotifier.setVisibleThread(-1) - contentResolver.unregisterContentObserver(screenshotObserver) } override fun getSystemService(name: String): Any? { @@ -2654,14 +2639,6 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, this.actionMode = null } - private fun sendScreenshotNotification() { - val recipient = viewModel.recipient - if (recipient.isGroupOrCommunityRecipient) return - val kind = DataExtractionNotification.Kind.Screenshot() - val message = DataExtractionNotification(kind) - MessageSender.send(message, recipient.address) - } - private fun sendMediaSavedNotification() { val recipient = viewModel.recipient if (recipient.isGroupOrCommunityRecipient) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index 466f862518..22343fc747 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -696,9 +696,6 @@ class MmsDatabase @Inject constructor( if (retrieved.isPushMessage) { type = type or MmsSmsColumns.Types.PUSH_MESSAGE_BIT } - if (retrieved.isScreenshotDataExtraction) { - type = type or MmsSmsColumns.Types.SCREENSHOT_EXTRACTION_BIT - } if (retrieved.isMediaSavedDataExtraction) { type = type or MmsSmsColumns.Types.MEDIA_SAVED_EXTRACTION_BIT } From 6a689270b4f5a1b3ae8e3856492b027387e10675 Mon Sep 17 00:00:00 2001 From: jbsession Date: Tue, 14 Oct 2025 09:48:11 +0800 Subject: [PATCH 042/219] SES-4672 : Changes to Mute/Camera buttons icon during calls (#1600) * Drawable state for webrtccall buttons * refactored xml name --- app/src/main/res/drawable/state_list_ic_mic.xml | 5 +++++ app/src/main/res/drawable/state_list_ic_video.xml | 5 +++++ app/src/main/res/layout/activity_webrtc.xml | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/state_list_ic_mic.xml create mode 100644 app/src/main/res/drawable/state_list_ic_video.xml diff --git a/app/src/main/res/drawable/state_list_ic_mic.xml b/app/src/main/res/drawable/state_list_ic_mic.xml new file mode 100644 index 0000000000..3044ce5cec --- /dev/null +++ b/app/src/main/res/drawable/state_list_ic_mic.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/state_list_ic_video.xml b/app/src/main/res/drawable/state_list_ic_video.xml new file mode 100644 index 0000000000..10170edc1f --- /dev/null +++ b/app/src/main/res/drawable/state_list_ic_video.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml index 77c2cc34c0..6af9ac9e20 100644 --- a/app/src/main/res/layout/activity_webrtc.xml +++ b/app/src/main/res/layout/activity_webrtc.xml @@ -180,7 +180,7 @@ android:id="@+id/enableCameraButton" android:background="@drawable/call_controls_background" app:tint="@color/state_list_call_action_foreground" - android:src="@drawable/ic_video" + android:src="@drawable/state_list_ic_video" android:padding="@dimen/medium_spacing" android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" @@ -195,7 +195,7 @@ android:layout_width="@dimen/large_button_height" android:layout_height="@dimen/large_button_height" android:padding="@dimen/medium_spacing" - android:src="@drawable/ic_mic_off" + android:src="@drawable/state_list_ic_mic" android:layout_marginBottom="@dimen/large_spacing" app:layout_constraintBottom_toTopOf="@+id/endCallButton" android:background="@drawable/call_controls_mic_background" From 259f8a7de9adc45718a6a2850eb674eb272e705b Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Wed, 15 Oct 2025 02:06:11 +0000 Subject: [PATCH 043/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 30 ------ app/src/main/res/values-b+cs+CZ/strings.xml | 33 ------- app/src/main/res/values-b+fr+FR/strings.xml | 25 ----- app/src/main/res/values-b+nl+NL/strings.xml | 25 ----- app/src/main/res/values-b+pl+PL/strings.xml | 22 ----- app/src/main/res/values-b+uk+UA/strings.xml | 20 ---- app/src/main/res/values/strings.xml | 103 ++++++++++---------- 7 files changed, 52 insertions(+), 206 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 36762fff08..4189fbdb13 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -198,7 +198,6 @@ {app_name} foto və video çəkmək və ya QR kodlarını skan etmək üçün kameraya müraciət etməlidir. {app_name} QR kodlarını skan etmək üçün kameraya müraciət etməlidir İmtina - Planı ləğv et Dəyişdir Parol dəyişdirmə uğursuz oldu {app_name} üçün parolunuzu dəyişdirin. Daxili olaraq saxlanılmış verilər, yeni parolunuzla təkrar şifrələnəcək. @@ -308,7 +307,6 @@ Yarat Zəng yaradılır Hazırkı parol - Hazırkı plan Kəs Qaranlıq rejim Bu cihazdan bütün mesajları, qoşmaları və hesab məlumatlarını silmək və yeni hesab yaratmaq istədiyinizə əminsinizmi? @@ -788,7 +786,6 @@ Yaxşı Açıq {device_type} cihazınızda - Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra planınızı {app_pro} ayarları vasitəsilə dəyişdirin. Hesab yarat Hesab yaradıldı Hesabım var @@ -891,7 +888,6 @@ Bildirişi önizlə Aktivləşdirildi Hər şey hazırdır! - {app_pro} planınız güncəlləndi! Hazırkı {pro} planınız avtomatik olaraq {date} tarixində yeniləndiyi zaman ödəniş haqqı alınacaq. Artıq yüksəltdiniz Getdik və ekran şəkliniz üçün GIF-lər və animasiyalı WebP təsvirləri yükləyin! {app_pro} ilə animasiyalı ekran şəkillərini endirin və premium özəlliklərin kilidini açın @@ -916,12 +912,9 @@ Daha uzun mesajlar göndərmək istəyirsiniz? {app_pro} ilə daha çox mətn göndərin və premium özəlliklərin kilidini açın Daha çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın 5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın - Hazırkı planınızda artıq tam {app_pro} qiymətinin {percent}% endirimi mövcuddur. {pro} statusunu təzələmə xətası Müddəti bitib - Təəssüf ki, {pro} planınızın müddəti bitib. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün yeniləyin. Tezliklə bitir - {pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin. {pro}, {time} vaxtında başa çatır {pro} TVS {app_pro} TVS-da tez-tez verilən suallara cavab tapın. @@ -936,7 +929,6 @@ %1$s qrup yüksəldildi %1$s qrup yüksəldildi - Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} planınız dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz. Artırılmış qoşma ölçüsü Artırılmış mesaj uzunluğu Daha böyük qruplar @@ -953,23 +945,6 @@ %1$s sancılmış danışıq %1$s sancılmış danışıq - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. Planınıza edilən güncəlləmələr növbəti {pro} yenilənməsi zamanı qüvvəyə minəcək. - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. - {app_pro} planınızın müddəti {date} tarixində bitir.\n\nEksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin. - {pro} planı xətası - {app_pro} planınızın müddəti {date} tarixində bitir. - {pro} planı yüklənir - {pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz. - {pro} planı yüklənir... - Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planı tapılmadı - Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. - {pro} planını geri qaytar - {pro} planını yenilə - Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin. - {app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. - {pro} planı bərpa edildi - {app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi! 1 ay - {monthly_price}/ay 3 ay - {monthly_price}/ay 12 ay - {monthly_price}/ay @@ -990,12 +965,9 @@ {pro} status yüklənir {pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {pro} ya yüksəldə bilməyəcəksiniz.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. {pro} statusunuzu təzələmək üçün şəbəkəyə bağlana bilmir. Bu səhifədəki bəzi əməliyyatlar, bağlantı bərpa olunana qədər sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planınızla bağlı kömək lazımdır? Dəstək komandamıza müraciət edin. Güncəlləyərək, {app_pro} Xidmət Şərtləri {icon} və Məxfilik Siyasəti {icon} ilə razılaşırsınız Limitsiz sancma Limitsiz sancılmış danışıqla bütün söhbətlərinizi təşkil edin. - Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək. - Planınız {date} tarixində bitəcək.\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək. {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz? Daha güclü mesajlaşma təcrübəsi üçün {app_pro}-ya yüksəldin. Profil Ekran şəkli @@ -1178,8 +1150,6 @@ Zəhmət olmasa, qrupun daha qısa təsvirini daxil edin {app_name} üçün yeni versiyası mövcuddur, güncəlləmək üçün toxunun Yeni {app_name} versiyası ({version}) mövcuddur. - Planı güncəllə - Planınızı güncəlləməyin iki yolu var: Profil məlumatlarını güncəllə Ekran adınız və ekran şəkliniz bütün danışıqlarda görünür. Buraxılış qeydlərinə get diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 0070c83ddd..81f8891675 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -200,7 +200,6 @@ {app_name} potřebuje přístup k fotoaparátu pro pořizování fotografií a videí nebo skenování QR kódů. {app_name} potřebuje přístup k fotoaparátu ke skenování QR kódů Zrušit - Zrušit tarif Změnit Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. @@ -312,7 +311,6 @@ Vytvořit Vytváření hovoru Aktuální heslo - Současný tarif Vyjmout Tmavý režim Opravdu chcete smazat všechny zprávy, přílohy a data účtu z tohoto zařízení a vytvořit nový účet? @@ -832,7 +830,6 @@ OK Zap. Na vašem zařízení {device_type} - Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté změňte svůj tarif v nastavení {app_pro}. Vytvořit účet Účet vytvořen Mám účet @@ -935,7 +932,6 @@ Náhled upozornění Aktivováno Vše je nastaveno! - Váš tarif {app_pro} byl aktualizován! Účtování proběhne při automatickém obnovení vašeho aktuálního tarifu {pro} dne {date}. Už máte Jako váš zobrazovaný profilový obrázek nastavte animovaný GIF nebo WebP! Získejte možnost nahrát animovaný zobrazovaný obrázek profilu a další prémiové funkce se Session Pro @@ -963,15 +959,9 @@ Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Zrušit - Dva způsoby, jak zrušit váš tarif: - Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Váš aktuální tarif je již zlevněn o {percent} % z plné ceny {app_pro}. Chyba obnovování stavu {pro} Platnost vypršela - Bohužel, váš tarif {pro} vypršel. Obnovte jej, abyste nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. Brzy vyprší - Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. {pro} vyprší za {time} {pro} FAQ Najděte odpovědi na časté dotazy v nápovědě {app_pro}. @@ -988,7 +978,6 @@ %1$s skupin navýšeno %1$s skupin navýšeno - Žádost o vrácení peněz je konečné. Pokud bude schváleno, váš tarif {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}. Navýšená velikost přílohy Navýšená délka zprávy Větší skupiny @@ -1011,23 +1000,6 @@ %1$s připnutých konverzací %1$s připnutých konverzací - Váš tarif {app_pro} je aktivní!\n\nVáš tarif se automaticky obnoví na další {current_plan} dne {date}. Změny tarifu se projeví při příštím obnovení {pro}. - Váš tarif {app_pro} je aktivní!\n\nTarif se automaticky obnoví na další {current_plan} dne {date}. - Váš tarif {app_pro} vyprší dne {date}.\n\nAktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro. - Chyba tarifu {pro} - Váš tarif {app_pro} vyprší dne {date}. - Načítání tarifu {pro} - Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit. - Načítání tarifu {pro}... - Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - {pro} nebyl nalezen - Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. - Znovu nabýt tarif {pro} - Obnovit tarif {pro} - Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}. - Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}. - {pro} obnoven - Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven! 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc @@ -1049,12 +1021,9 @@ stav načítání {pro} Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze provést navýšení na {pro}.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - Potřebujete pomoc se svým tarifem {pro}? Pošlete žádost týmu podpory. Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Neomezený počet připnutí Organizujte si komunikaci pomocí neomezeného počtu připnutých konverzací. - V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?\n\nPo aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}. - Váš tarif vyprší {date}.\n\nPo aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro. Chcete z {app_name} získat více? Navyštee na {app_pro} pro výkonnější posílání zpráv. Profil Zobrazovaný obrázek @@ -1245,8 +1214,6 @@ Zadejte prosím kratší popis skupiny Je dostupná nová verze {app_name}, klikněte pro aktualizaci Je dostupná nová verze ({version}) aplikace {app_name}. - Aktualizovat tarif - Dva způsoby, jak aktualizovat váš tarif: Upravit informace profilu Vaše zobrazované jméno a profilová fotka jsou viditelné ve všech konverzacích. Přejít na poznámky k vydání diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index f1d4c55520..c3e01a6a3b 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -198,7 +198,6 @@ {app_name} a besoin de l’autorisation Caméra pour prendre des photos ou des vidéos, ou scanner des codes QR. {app_name} a besoin d\'accéder à l\'appareil photo pour scanner les codes QR Annuler - Annuler l’abonnement Modifier Échec de changement de mot de passe Changez votre mot de passe pour {app_name}. Les données stockées localement seront re-chiffrées avec votre nouveau mot de passe. @@ -306,7 +305,6 @@ Créer Création de l\'appel Mot de passe actuel - Forfait actuel Couper Mode sombre Êtes-vous sûr de vouloir supprimer tous les messages, pièces jointes et données de compte de cet appareil et créer un nouveau compte ? @@ -786,7 +784,6 @@ Okay Activé Sur votre appareil {device_type} - Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}. Créer un compte Compte Créé J\'ai un compte @@ -889,7 +886,6 @@ Aperçu de la notification Activé Tout est prêt ! - Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}. Vous avez déjà Téléchargez des GIF et des images WebP animées pour votre photo de profil ! Obtenez des photos de profil animées et débloquez des fonctionnalités premium avec {app_pro} @@ -914,11 +910,8 @@ Vous voulez envoyer des messages plus longs ? Envoyez plus de messages et débloqué les fonctionnalités premium avec {app_pro} Vous voulez plus de messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} - Votre abonnement actuel bénéficie déjà d\'une remise de {percent}% sur le prix de {app_pro}. Expiré - Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. Expiration imminente - Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. {pro} expire dans {time} FAQ {pro} Trouvez des réponses aux questions fréquentes dans la FAQ de {app_pro}. @@ -933,7 +926,6 @@ %1$s groupe mis à niveau %1$s groupes mis à niveau - La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l\'accès à toutes les fonctionnalités {pro}. Taille de pièce jointe augmentée Longueur de message augmentée Groupes plus grands @@ -950,18 +942,6 @@ %1$s Conversation épinglée %1$s Conversations épinglées - Votre formule {app_pro} est active !\n\nVotre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}. - Votre forfait {app_pro} est actif\n\nVotre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}. - Votre offre {app_pro} expirera le {date}.\n\nMettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro. - Votre forfait {app_pro} expirera le {date}. - Forfait {pro} introuvable - Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide. - Récupérer le forfait {pro} - Renouveler l’abonnement {pro} - Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}. - Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}. - {pro} Forfait rétabli - Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré ! 1 mois – {monthly_price} / mois 3 mois - {monthly_price} / mois 12 mois - {monthly_price} / mois @@ -973,12 +953,9 @@ Paramètres {pro} Vos statistiques {pro} Les statistiques {pro} reflètent l\'utilisation sur cet appareil et peuvent apparaître différemment sur les appareils connectés - Besoin d\'aide avec votre forfait {pro} ? Envoyez une demande à l\'équipe d\'assistance. En mettant à jour, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} Épingles illimitées Organisez toutes vos discussions avec un nombre illimité de conversations épinglées. - Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?\n\nEn mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l\'accès {pro}. - Votre forfait expirera le {date}.\n\nEn le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro. Vous voulez tirer le meilleur parti de {app_name} ? Passez à {app_pro} pour une expérience de messagerie améliorée. Profil Définir une photo de profil @@ -1160,8 +1137,6 @@ Veuillez entrer une description de groupe plus courte Une nouvelle version de {app_name} est disponible, appuyez pour lancer la mise à jour Une nouvelle version ({version}) de {app_name} est disponible. - Mettre à jour le forfait - Deux façons de mettre à jour votre abonnement : Mettre à jour les informations du profil Votre nom d\'affichage et votre photo de profil sont visibles dans toutes les conversations. Accéder aux notes de mise à jour diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index c823b46604..cf6041168a 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -198,7 +198,6 @@ {app_name} heeft toegang tot de camera nodig om foto\'s en video\'s te maken of QR-codes te scannen. {app_name} heeft toegang tot de camera nodig om QR-codes te scannen Annuleren - Abonnement annuleren Wijzigen Wachtwoord wijzigen mislukt Wijzig je wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met je nieuwe wachtwoord. @@ -306,7 +305,6 @@ Aanmaken Oproep starten Huidig wachtwoord - Huidig abonnement Knippen Donkere modus Weet u zeker dat u alle berichten, bijlagen en accountgegevens van dit apparaat wilt verwijderen en een nieuw account wilt aanmaken? @@ -783,7 +781,6 @@ Oké Aan Op je {device_type} apparaat - Open dit {app_name} account op een {device_type} apparaat waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Wijzig vervolgens je abonnement via de instellingen van {app_pro}. Account aanmaken Account aangemaakt Ik heb een account @@ -886,7 +883,6 @@ Voorbeeldmelding Geactiveerd Alles is geregeld! - Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}. Je hebt al Upload nu GIF\'s en geanimeerde WebP-afbeeldingen voor je profielfoto! Krijg geanimeerde profielfoto\'s en ontgrendel premiumfuncties met {app_pro} @@ -907,11 +903,8 @@ Wil je langere berichten versturen? Verstuur meer tekst en ontgrendel premiumfuncties met {app_pro} Wil je meer vastzetten? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} - Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs. Verlopen - Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. Verloopt binnenkort - Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. {pro} FAQ Vind antwoorden op veelgestelde vragen in de {app_pro} FAQ. Upload GIF- en WebP-profielfoto\'s @@ -921,7 +914,6 @@ Onbeperkt gesprekken vastzetten Groep geactiveerd Deze groep heeft een grotere capaciteit! Er kunnen nu tot 300 leden deelnemen omdat een groepsbeheerder een - Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies. Verhoogde bijlagegrootte Verlengde berichtlengte Grotere groepen @@ -930,18 +922,6 @@ Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken. Dit bericht maakte gebruik van de volgende {app_pro}-functies: {percent}% korting - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd. - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd met een {current_plan} op {date}. - Je {app_pro} abonnement verloopt op {date}.\n\nWerk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies. - Je {app_pro} abonnement verloopt op {date}. - {pro} abonnement niet gevonden - Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp. - {pro} abonnement herstellen - {pro} abonnement verlengen - Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies. - Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}. - {pro} abonnement hersteld - Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld! 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand @@ -953,12 +933,9 @@ {pro} instellingen Je {pro} statistieken {pro} statistieken weerspiegelen het gebruik op dit apparaat en kunnen anders weergegeven worden op gekoppelde apparaten - Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam. Door bij te werken ga je akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} Onbeperkte Pins Organiseer al je chats met onbeperkt vastgezette gesprekken. - Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?\n\nAls je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang. - Je abonnement verloopt op {date}.\n\nDoor bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang. Wil je meer uit {app_name} halen? Upgrade naar {app_pro} voor een krachtigere berichtbeleving. Profiel Toon afbeelding @@ -1136,8 +1113,6 @@ Vul alstublieft een kortere groepsnaam in Er is een nieuwe versie van {app_name} beschikbaar, tik om bij te werken Versie ({version}) van {app_name} is beschikbaar. - Abonnement bijwerken - Twee manieren om je abonnement bij te werken: Profielinformatie bijwerken Je weergavenaam en profielfoto zijn zichtbaar in alle gesprekken. Ga naar Release Opmerkingen diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index f84d6b2b93..9759ed9491 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -200,7 +200,6 @@ Aby robić zdjęcia, nagrywać filmy i skanować kody QR, aplikacja {app_name} potrzebuje dostępu do aparatu Aby skanować kody QR, aplikacja {app_name} wymaga dostępu do aparatu Anulowanie - Anuluj plan Zmień Nie udało się zmienić hasła Zmień hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane nowym hasłem. @@ -310,7 +309,6 @@ Utwórz Tworzenie połączenia Obecne hasło - Obecny plan Wytnij Tryb ciemny Czy na pewno chcesz usunąć wszystkie wiadomości, załączniki i dane konta z tego urządzenia i utworzyć nowe konto? @@ -925,7 +923,6 @@ Podgląd powiadomień Aktywowano Wszystko gotowe! - Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}. Masz już Możesz przesyłać GIF-y i animowane obrazy WebP jako swoje zdjęcie profilowe! Zyskaj animowane zdjęcia profilowe i odblokuj funkcje premium dzięki {app_pro} @@ -952,10 +949,7 @@ Chcesz wysyłać dłuższe wiadomości? Wyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} Chcesz przypinać więcej czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} - Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}. - Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}. Niedługo wygaśnie - Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}. FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. Prześlij obrazy profilowe w formacie GIF i WebP @@ -971,7 +965,6 @@ Ulepszono %1$s grup Ulepszono %1$s grup - Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}. Zwiększony rozmiar załączników Zwiększona długość wiadomości Większe grupy @@ -990,18 +983,6 @@ %1$s przypiętych konwersacji %1$s przypiętych konwersacji - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}. - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. - Twój plan {app_pro} wygasa {date}.\n\nZaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro. - Twój plan {app_pro} wygasa {date}. - Nie znaleziono planu {pro} - Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}. - Odzyskaj plan {pro} - Odnów plan {pro} - Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta. - Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}. - Plan {pro} został odzyskany - Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}! 1 miesiąc - {monthly_price} / miesiąc 3 miesiące - {monthly_price} / miesiąc 12 miesięcy - {monthly_price} / miesiąc @@ -1012,7 +993,6 @@ Ustawienia {pro} Twoje Statystyki {pro} Statystyki {pro} pokazują użycie na tym urządzeniu i mogą wyglądać różnie na połączonych urządzeniach - Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia. Dokonując zmian wyrażasz zgodę na Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon} Nielimitowane przypięcia Organizuj swoje czaty z nielimitowaną możliwością przypinania konwersacji. @@ -1199,8 +1179,6 @@ Wprowadź krótszy opis grupy Dostępna jest nowa wersja aplikacji {app_name}. Stuknij, aby zaktualizować Dostępna jest nowa wersja ({version}) aplikacji {app_name}. - Zaktualizuj plan - Dwa sposoby na aktualizację planu: Zaktualizuj informacje w profilu Twoja nazwa wyświetlana i obraz profilu są widoczne we wszystkich konwersacjach. Przejdź do informacji o wersji diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index e57ea49123..9c34c445c7 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -200,7 +200,6 @@ {app_name} потребує доступ до камери, щоб фотографувати, знімати відео або сканувати QR-коди. {app_name} потрібен дозвіл до камери для сканування QR-кодів Скасувати - Скасувати тарифний план Змінити Не вдалося змінити пароль Змінити ваш пароль для {app_name}. Локально збережені дані будуть наново шифровані з застосуванням нового паролю. @@ -310,7 +309,6 @@ Створити Викликаємо Поточний пароль - Поточна передплата Вирізати Темний режим Ви впевнені, що хочете видалити всі повідомлення, вкладення та дані облікового запису з цього пристрою та створити новий обліковий запис? @@ -932,7 +930,6 @@ Попередній перегляд сповіщень активовано Готово! - Твою підписку {app_pro} оновлено. {date} коли підписку {pro} буде подовжено, тоді й стягнуть гроші. У вас вже є Не зволікайте і завантажуйте GIF та анімовані WebP картинки для свого аватара! Отримайте анімовані аватари та розблокуйте преміальні функції з {app_pro} @@ -953,11 +950,8 @@ Хочете відправляти довші повідомлення? Надсилайте більше тексту та розблокуйте преміальні функції застосунку з Session Pro Потрібно більше закріплених бесід? Впорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro} - На поточну підписку ти вже маєш знижку {percent}% від загальної ціни {app_pro}. Підписка сплила - На жаль, підписка {pro} сплила. Онови її задля збереження переваг і можливостей {app_pro}. Невдовзі спливе підписка - Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}. {pro} спливає за {time} {pro} ЧАП Відповіді на загальні запитання знайдеш у ЧаПи {app_pro}. @@ -968,7 +962,6 @@ Закріплюйте необмежену кількість бесід Групу активовано У цієї групи розширено можливості! Тепер вона може вміщати до 300 учасників, тому що адміністратор групи має - Вимагання повернення грошей закінчено. В разі схвалення твою підписку {pro} негайно скасують і ти втратиш всі можливості {pro}. Збільшений розмір вкладення Збільшена довжина повідомлень Більші групи @@ -977,15 +970,6 @@ Ви можете надсилати повідомлення до 10 000 символів у всіх розмовах. У цьому повідомленні наявні наступні функції Session Pro: {percent}% Знижки - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. Оновлення підписки настане під час наступного оновлення {pro}. - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. - Твоя підписка {app_pro} спливе {date}.\n\nДля збереження особливих можливостей подовж свою підписку. - Підписка {app_pro} спливе {date}. - Передплата {pro} не знайдена - Відновити передплату {pro} - Оновити підписку {pro} - План {pro} відновлено - Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено! 1 місяць — {monthly_price} / місяць 3 місяці — {monthly_price} / місяць 12 місяців – {monthly_price} / місяць @@ -996,11 +980,9 @@ Налаштування {pro} Ваша статистика {pro} Звіти підписки {pro} відображають використання лише цього пристрою, тож, мабуть, матимуть иншого вигляду на инших пристроях - Якщо потребуєш допомоги щодо підписки {pro}, надійшли звернення до відділу підтримки. Цією дією ти надаси згоду щодо дотримання Правил послуги {app_pro} {icon} і Ставлення до особистих відомостей {icon} Необмежена кількість закріплених бесід Закріплення необмеженої кількості співрозмовників в головному переліку. - Ваш план завершиться {date}.\n\nПісля оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro. Хочете отримати більше від {app_name}? Оновіться до {app_pro}, щоб мати потужніший досвід обміну повідомленнями. Профіль Аватар @@ -1190,8 +1172,6 @@ Будь ласка, введіть коротший опис групи Нова версія {app_name} доступна. Доступна нова версія ({version}) {app_name}. - Оновити тарифний план - Два шляхи поновлення твоєї підписки: Оновити інформацію облікового запису Ваше відображуване ім’я та зображення профілю видимі у всіх розмовах. Перейти в примітки до випуску diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4dc0a1c7d6..af9d9c7afd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,10 +203,10 @@ {app_name} needs camera access to take photos and videos, or scan QR codes. {app_name} needs camera access to scan QR codes Cancel - Cancel Plan + Cancel {pro} Cancel {pro} Plan - Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with. - Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with. + Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with. + Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with. Change Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. @@ -318,8 +318,8 @@ Copy Create Creating Call + Current Billing Current Password - Current Plan Cut Dark Mode Are you sure you want to delete all messages, attachments, and account data from this device and create a new account? @@ -484,7 +484,7 @@ Copy Error and Quit Database Error Something went wrong. Please try again later. - Error loading {pro} plan + Error loading {pro} access An unknown error occurred. Failed to download Failures @@ -813,8 +813,8 @@ Okay On On your {device_type} device - Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings. - Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel {pro} via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, update your {pro} access via the {app_pro} settings. On a linked device On the {platform_store} website On the {platform} website @@ -921,9 +921,28 @@ Preferences Preview Preview Notification + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another {current_plan} on {date}. + Your {pro} access will expire on {date}.\n\nUpdate your {pro} access now to ensure you automatically renew before your {pro} access expires. + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another\n{current_plan} on {date}. Any updates you make here will take effect at your next renewal. + {pro} Access Error + Your {pro} access will expire on {date}. + {pro} Access Loading + Your {pro} access information is still being loaded. You cannot update until this process is complete. + {pro} access loading... + Unable to connect to the network to load your {pro} access information. Updating {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + {pro} Access Not Found + {app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance. + Recover {pro} Access + Renew {pro} Access + Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with. + Renew on the {platform} website using the {platform_account} you signed up for {pro} with. + Renew your {pro} access to start using powerful {app_pro} Beta features again. + {pro} Access Recovered + {app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored! + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your {pro} access. Activated You\'re all set! - Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}. + Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}. You’ve already got Go ahead and upload GIFs and animated WebP images for your display picture! Get animated display pictures and unlock premium features with {app_pro} @@ -948,19 +967,19 @@ Want to send longer messages? Send more text and unlock premium features with {app_pro} Want more pins? Organize your chats and unlock premium features with {app_pro} Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} - We’re sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {app_pro} plan. + Sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {pro} access. Cancellation - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. - Two ways to cancel your plan: - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires. - Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Your current plan is already discounted by {percent}% of the full {app_pro} price. + Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. + Two ways to cancel your {pro} access: + Canceling {pro} access will prevent will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. + Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Your {pro} access is already discounted by {percent}% of the full {app_pro} price. Error refreshing {pro} status Expired - Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}. + Unfortunately, your {pro} access has expired. Renew to reactivate the exclusive perks and features of {app_pro}. Expiring Soon - Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}. + Your {pro} access is expiring in {time}. Update now to keep accessing the exclusive perks and features of {app_pro}. {pro} expiring in {time} {pro} FAQ Find answers to common questions in the {app_pro} FAQ. @@ -975,7 +994,7 @@ %1$s Group Upgraded %1$s Groups Upgraded - Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features. + Requesting a refund is final. If approved, your {pro} access will be canceled immediately and you will lose access to all {pro} features. Increased Attachment Size Increased Message Length Larger Groups @@ -989,37 +1008,17 @@ This message used the following {app_pro} features: With a new installation - Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings. + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings. Currently, there are three ways to renew: {percent}% Off %1$s Pinned Conversation %1$s Pinned Conversations - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed. - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. - Your {app_pro} plan will expire on {date}.\n\nUpdate your plan now to ensure uninterrupted access to exclusive Pro features. - {pro} Plan Error - Your {app_pro} plan will expire on {date}. - {pro} Plan Loading - Information about your {pro} plan is still being loaded. You cannot update your plan until this process is complete. - {pro} plan loading... - Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - {pro} Plan Not Found - No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance. Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to request a refund. Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Recover {pro} Plan - Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. - Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. - Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with. - Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. - Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}. - {pro} Plan Restored - A valid plan for {app_pro} was detected and your {pro} status has been restored! - Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your plan. + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Your {app_pro} access has been renewed! Thank you for supporting the {network_name}. 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month 12 Months - {monthly_price} / Month @@ -1030,9 +1029,10 @@ Your refund request will be handled exclusively by {platform} through the {platform} website.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform} Refund Support Refunding {pro} - Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings @@ -1047,13 +1047,13 @@ {pro} status loading Unable to connect to the network to check your {pro} status. You cannot upgrade to {pro} until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to refresh your {pro} status. Some actions on this page will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - Need help with your {pro} plan? Submit a request to the support team. + Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + Need help with {pro}? Submit a request to the support team. By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Unlimited Pins Organize all your chats with unlimited pinned conversations. - You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access. - Your plan will expire on {date}.\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access. + Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan length} of {pro} access. + Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. {platform} is processing your refund request Profile @@ -1111,7 +1111,7 @@ This is your recovery password. If you send it to someone they\'ll have full access to your account. Recreate Group Redo - Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your plan. + Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your {pro} access. Two ways to request a refund: Reduce message length by {count} @@ -1228,6 +1228,9 @@ Undo Unknown Unsupported CPU + Update + Update {pro} Access + Two ways to update your {pro} access: App updates Update Community Information Community name and description are visible to all community members @@ -1242,8 +1245,6 @@ Please enter a shorter group description A new version of {app_name} is available, tap to update A new version ({version}) of {app_name} is available. - Update Plan - Two ways to update your plan: Update Profile Information Your display name and display picture are visible in all conversations. Go to Release Notes @@ -1263,7 +1264,7 @@ Use Fast Mode Change your plan using the {platform_account} you used to sign up with, via the {platform} website . Via the {platform} website - Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website . + Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website. Video Unable to play video. View From 0de2f3a1e590b2ad79f98042853b09a485f216db Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Wed, 15 Oct 2025 03:16:00 +0000 Subject: [PATCH 044/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 30 ------ app/src/main/res/values-b+cs+CZ/strings.xml | 33 ------- app/src/main/res/values-b+fr+FR/strings.xml | 25 ----- app/src/main/res/values-b+nl+NL/strings.xml | 25 ----- app/src/main/res/values-b+pl+PL/strings.xml | 22 ----- app/src/main/res/values-b+uk+UA/strings.xml | 20 ---- app/src/main/res/values/strings.xml | 103 ++++++++++---------- 7 files changed, 52 insertions(+), 206 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 36762fff08..4189fbdb13 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -198,7 +198,6 @@ {app_name} foto və video çəkmək və ya QR kodlarını skan etmək üçün kameraya müraciət etməlidir. {app_name} QR kodlarını skan etmək üçün kameraya müraciət etməlidir İmtina - Planı ləğv et Dəyişdir Parol dəyişdirmə uğursuz oldu {app_name} üçün parolunuzu dəyişdirin. Daxili olaraq saxlanılmış verilər, yeni parolunuzla təkrar şifrələnəcək. @@ -308,7 +307,6 @@ Yarat Zəng yaradılır Hazırkı parol - Hazırkı plan Kəs Qaranlıq rejim Bu cihazdan bütün mesajları, qoşmaları və hesab məlumatlarını silmək və yeni hesab yaratmaq istədiyinizə əminsinizmi? @@ -788,7 +786,6 @@ Yaxşı Açıq {device_type} cihazınızda - Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra planınızı {app_pro} ayarları vasitəsilə dəyişdirin. Hesab yarat Hesab yaradıldı Hesabım var @@ -891,7 +888,6 @@ Bildirişi önizlə Aktivləşdirildi Hər şey hazırdır! - {app_pro} planınız güncəlləndi! Hazırkı {pro} planınız avtomatik olaraq {date} tarixində yeniləndiyi zaman ödəniş haqqı alınacaq. Artıq yüksəltdiniz Getdik və ekran şəkliniz üçün GIF-lər və animasiyalı WebP təsvirləri yükləyin! {app_pro} ilə animasiyalı ekran şəkillərini endirin və premium özəlliklərin kilidini açın @@ -916,12 +912,9 @@ Daha uzun mesajlar göndərmək istəyirsiniz? {app_pro} ilə daha çox mətn göndərin və premium özəlliklərin kilidini açın Daha çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın 5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın - Hazırkı planınızda artıq tam {app_pro} qiymətinin {percent}% endirimi mövcuddur. {pro} statusunu təzələmə xətası Müddəti bitib - Təəssüf ki, {pro} planınızın müddəti bitib. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün yeniləyin. Tezliklə bitir - {pro} planınızın müddəti {time} vaxtında bitir. {app_pro} tətbiqinin eksklüziv imtiyazlarına və özəlliklərinə erişimi davam etdirmək üçün planınızı güncəlləyin. {pro}, {time} vaxtında başa çatır {pro} TVS {app_pro} TVS-da tez-tez verilən suallara cavab tapın. @@ -936,7 +929,6 @@ %1$s qrup yüksəldildi %1$s qrup yüksəldildi - Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} planınız dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz. Artırılmış qoşma ölçüsü Artırılmış mesaj uzunluğu Daha böyük qruplar @@ -953,23 +945,6 @@ %1$s sancılmış danışıq %1$s sancılmış danışıq - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. Planınıza edilən güncəlləmələr növbəti {pro} yenilənməsi zamanı qüvvəyə minəcək. - {app_pro} planınız aktivdir!\n\nPlanınız avtomatik olaraq {date} tarixində başqa bir {current_plan} üçün yenilənəcək. - {app_pro} planınızın müddəti {date} tarixində bitir.\n\nEksklüziv Pro özəlliklərinə kəsintisiz erişimi təmin etmək üçün planınızı indi güncəlləyin. - {pro} planı xətası - {app_pro} planınızın müddəti {date} tarixində bitir. - {pro} planı yüklənir - {pro} planınız barədə məlumatlar hələ də yüklənir. Bu proses tamamlanana qədər planınızı güncəlləyə bilməzsiniz. - {pro} planı yüklənir... - Hazırkı planınızı yükləmək üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {app_name} ilə planınızı güncəlləmək sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planı tapılmadı - Hesabınız üçün heç bir aktiv plan tapılmadı. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. - {pro} planını geri qaytar - {pro} planını yenilə - Güclü {app_pro} özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} planınızı yeniləyin. - {app_pro} planınız yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. - {pro} planı bərpa edildi - {app_pro} üçün yararlı bir plan aşkarlandı və {pro} statusunuz bərpa edildi! 1 ay - {monthly_price}/ay 3 ay - {monthly_price}/ay 12 ay - {monthly_price}/ay @@ -990,12 +965,9 @@ {pro} status yüklənir {pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {pro} ya yüksəldə bilməyəcəksiniz.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. {pro} statusunuzu təzələmək üçün şəbəkəyə bağlana bilmir. Bu səhifədəki bəzi əməliyyatlar, bağlantı bərpa olunana qədər sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. - {pro} planınızla bağlı kömək lazımdır? Dəstək komandamıza müraciət edin. Güncəlləyərək, {app_pro} Xidmət Şərtləri {icon} və Məxfilik Siyasəti {icon} ilə razılaşırsınız Limitsiz sancma Limitsiz sancılmış danışıqla bütün söhbətlərinizi təşkil edin. - Hazırda {current_plan} Planı üzərindəsiniz. {selected_plan} Planınana keçmək istədiyinizə əminsiniz?\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} {pro} erişimi üçün avtomatik yenilənəcək. - Planınız {date} tarixində bitəcək.\n\nGüncəlləsəniz, planınız {date} tarixində əlavə {selected_plan} Pro erişimi üçün avtomatik olaraq yenilənəcək. {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz? Daha güclü mesajlaşma təcrübəsi üçün {app_pro}-ya yüksəldin. Profil Ekran şəkli @@ -1178,8 +1150,6 @@ Zəhmət olmasa, qrupun daha qısa təsvirini daxil edin {app_name} üçün yeni versiyası mövcuddur, güncəlləmək üçün toxunun Yeni {app_name} versiyası ({version}) mövcuddur. - Planı güncəllə - Planınızı güncəlləməyin iki yolu var: Profil məlumatlarını güncəllə Ekran adınız və ekran şəkliniz bütün danışıqlarda görünür. Buraxılış qeydlərinə get diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 0070c83ddd..81f8891675 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -200,7 +200,6 @@ {app_name} potřebuje přístup k fotoaparátu pro pořizování fotografií a videí nebo skenování QR kódů. {app_name} potřebuje přístup k fotoaparátu ke skenování QR kódů Zrušit - Zrušit tarif Změnit Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. @@ -312,7 +311,6 @@ Vytvořit Vytváření hovoru Aktuální heslo - Současný tarif Vyjmout Tmavý režim Opravdu chcete smazat všechny zprávy, přílohy a data účtu z tohoto zařízení a vytvořit nový účet? @@ -832,7 +830,6 @@ OK Zap. Na vašem zařízení {device_type} - Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté změňte svůj tarif v nastavení {app_pro}. Vytvořit účet Účet vytvořen Mám účet @@ -935,7 +932,6 @@ Náhled upozornění Aktivováno Vše je nastaveno! - Váš tarif {app_pro} byl aktualizován! Účtování proběhne při automatickém obnovení vašeho aktuálního tarifu {pro} dne {date}. Už máte Jako váš zobrazovaný profilový obrázek nastavte animovaný GIF nebo WebP! Získejte možnost nahrát animovaný zobrazovaný obrázek profilu a další prémiové funkce se Session Pro @@ -963,15 +959,9 @@ Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Zrušit - Dva způsoby, jak zrušit váš tarif: - Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj tarif {pro} později obnovit. - Váš aktuální tarif je již zlevněn o {percent} % z plné ceny {app_pro}. Chyba obnovování stavu {pro} Platnost vypršela - Bohužel, váš tarif {pro} vypršel. Obnovte jej, abyste nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. Brzy vyprší - Váš tarif {pro} vyprší za {time}. Aktualizujte si tarif, abyste i nadále měli přístup k exkluzivním výhodám a funkcím {app_pro}. {pro} vyprší za {time} {pro} FAQ Najděte odpovědi na časté dotazy v nápovědě {app_pro}. @@ -988,7 +978,6 @@ %1$s skupin navýšeno %1$s skupin navýšeno - Žádost o vrácení peněz je konečné. Pokud bude schváleno, váš tarif {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}. Navýšená velikost přílohy Navýšená délka zprávy Větší skupiny @@ -1011,23 +1000,6 @@ %1$s připnutých konverzací %1$s připnutých konverzací - Váš tarif {app_pro} je aktivní!\n\nVáš tarif se automaticky obnoví na další {current_plan} dne {date}. Změny tarifu se projeví při příštím obnovení {pro}. - Váš tarif {app_pro} je aktivní!\n\nTarif se automaticky obnoví na další {current_plan} dne {date}. - Váš tarif {app_pro} vyprší dne {date}.\n\nAktualizujte si tarif nyní, abyste měli i nadále nepřerušený přístup k exkluzivním funkcím Pro. - Chyba tarifu {pro} - Váš tarif {app_pro} vyprší dne {date}. - Načítání tarifu {pro} - Váš tarif {pro} se stále načítá. Dokud nebude načítání dokončeno, nemůžete tarif změnit. - Načítání tarifu {pro}... - Nelze se připojit k síti a načíst váš aktuální tarif. Aktualizace tarifu prostřednictvím {app_name} bude deaktivována, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - {pro} nebyl nalezen - Pro váš účet nebyl nalezen žádný aktivní tarif. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. - Znovu nabýt tarif {pro} - Obnovit tarif {pro} - Obnovte váš tarif {app_pro}, abyste mohli znovu využívat užitečné funkce {app_pro}. - Váš tarif {app_pro} byl obnoven! Děkujeme, že podporujete síť {network_name}. - {pro} obnoven - Byl rozpoznán platný tarif {app_pro} a váš stav {pro} byl obnoven! 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc @@ -1049,12 +1021,9 @@ stav načítání {pro} Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze provést navýšení na {pro}.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. - Potřebujete pomoc se svým tarifem {pro}? Pošlete žádost týmu podpory. Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Neomezený počet připnutí Organizujte si komunikaci pomocí neomezeného počtu připnutých konverzací. - V současné době jste na tarifu {current_plan}. Jste si jisti, že chcete přepnout na tarif {selected_plan}?\n\nPo aktualizaci bude váš tarif automaticky obnoven {date} na další {selected_plan} přístupu {pro}. - Váš tarif vyprší {date}.\n\nPo aktualizaci se váš tarif automaticky obnoví {date} na další {selected_plan} přístupu Pro. Chcete z {app_name} získat více? Navyštee na {app_pro} pro výkonnější posílání zpráv. Profil Zobrazovaný obrázek @@ -1245,8 +1214,6 @@ Zadejte prosím kratší popis skupiny Je dostupná nová verze {app_name}, klikněte pro aktualizaci Je dostupná nová verze ({version}) aplikace {app_name}. - Aktualizovat tarif - Dva způsoby, jak aktualizovat váš tarif: Upravit informace profilu Vaše zobrazované jméno a profilová fotka jsou viditelné ve všech konverzacích. Přejít na poznámky k vydání diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index f1d4c55520..c3e01a6a3b 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -198,7 +198,6 @@ {app_name} a besoin de l’autorisation Caméra pour prendre des photos ou des vidéos, ou scanner des codes QR. {app_name} a besoin d\'accéder à l\'appareil photo pour scanner les codes QR Annuler - Annuler l’abonnement Modifier Échec de changement de mot de passe Changez votre mot de passe pour {app_name}. Les données stockées localement seront re-chiffrées avec votre nouveau mot de passe. @@ -306,7 +305,6 @@ Créer Création de l\'appel Mot de passe actuel - Forfait actuel Couper Mode sombre Êtes-vous sûr de vouloir supprimer tous les messages, pièces jointes et données de compte de cet appareil et créer un nouveau compte ? @@ -786,7 +784,6 @@ Okay Activé Sur votre appareil {device_type} - Ouvrez ce compte {app_name} sur un appareil {device_type} connecté au compte {platform_account} avec lequel vous vous êtes inscrit à l\'origine. Ensuite, modifiez votre abonnement via les paramètres {app_pro}. Créer un compte Compte Créé J\'ai un compte @@ -889,7 +886,6 @@ Aperçu de la notification Activé Tout est prêt ! - Votre forfait {app_pro} a été mis à jour ! Vous serez facturé lorsque votre forfait {pro} actuel sera automatiquement renouvelé le {date}. Vous avez déjà Téléchargez des GIF et des images WebP animées pour votre photo de profil ! Obtenez des photos de profil animées et débloquez des fonctionnalités premium avec {app_pro} @@ -914,11 +910,8 @@ Vous voulez envoyer des messages plus longs ? Envoyez plus de messages et débloqué les fonctionnalités premium avec {app_pro} Vous voulez plus de messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} - Votre abonnement actuel bénéficie déjà d\'une remise de {percent}% sur le prix de {app_pro}. Expiré - Malheureusement, votre formule {pro} a expiré. Renouvelez-la pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. Expiration imminente - Votre formule {pro} expire dans {time}. Mettez à jour votre formule pour continuer à profiter des avantages et fonctionnalités exclusifs de {app_pro}. {pro} expire dans {time} FAQ {pro} Trouvez des réponses aux questions fréquentes dans la FAQ de {app_pro}. @@ -933,7 +926,6 @@ %1$s groupe mis à niveau %1$s groupes mis à niveau - La demande de remboursement est définitive. Si elle est approuvée, votre formule {pro} sera immédiatement annulée et vous perdrez l\'accès à toutes les fonctionnalités {pro}. Taille de pièce jointe augmentée Longueur de message augmentée Groupes plus grands @@ -950,18 +942,6 @@ %1$s Conversation épinglée %1$s Conversations épinglées - Votre formule {app_pro} est active !\n\nVotre formule sera automatiquement renouvelée pour une autre {current_plan} le {date}. Les modifications apportées à votre formule prendront effet lors du prochain renouvellement de {pro}. - Votre forfait {app_pro} est actif\n\nVotre abonnement se renouvellera automatiquement pour un autre {current_plan} le {date}. - Votre offre {app_pro} expirera le {date}.\n\nMettez votre offre à jour maintenant pour garantir un accès ininterrompu aux fonctionnalités exclusives Pro. - Votre forfait {app_pro} expirera le {date}. - Forfait {pro} introuvable - Aucun forfait actif n’a été trouvé pour votre compte. Si vous pensez qu’il s’agit d’une erreur, veuillez contacter l’assistance de {app_name} pour obtenir de l’aide. - Récupérer le forfait {pro} - Renouveler l’abonnement {pro} - Renouvelez votre abonnement {app_pro} pour recommencer à utiliser les puissantes fonctionnalités bêta de {app_pro}. - Votre forfait {app_pro} a été renouvelé ! Merci de soutenir le {network_name}. - {pro} Forfait rétabli - Un abonnement valide pour {app_pro} a été détecté et votre statut {pro} a été restauré ! 1 mois – {monthly_price} / mois 3 mois - {monthly_price} / mois 12 mois - {monthly_price} / mois @@ -973,12 +953,9 @@ Paramètres {pro} Vos statistiques {pro} Les statistiques {pro} reflètent l\'utilisation sur cet appareil et peuvent apparaître différemment sur les appareils connectés - Besoin d\'aide avec votre forfait {pro} ? Envoyez une demande à l\'équipe d\'assistance. En mettant à jour, vous acceptez les Conditions d\'utilisation {icon} et la Politique de confidentialité {icon} de {app_pro} Épingles illimitées Organisez toutes vos discussions avec un nombre illimité de conversations épinglées. - Vous êtes actuellement sur le forfait {current_plan}. Voulez-vous vraiment passer au forfait {selected_plan}?\n\nEn mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaire de l\'accès {pro}. - Votre forfait expirera le {date}.\n\nEn le mettant à jour, votre forfait sera automatiquement renouvelé le {date} pour {selected_plan} supplémentaires d’accès Pro. Vous voulez tirer le meilleur parti de {app_name} ? Passez à {app_pro} pour une expérience de messagerie améliorée. Profil Définir une photo de profil @@ -1160,8 +1137,6 @@ Veuillez entrer une description de groupe plus courte Une nouvelle version de {app_name} est disponible, appuyez pour lancer la mise à jour Une nouvelle version ({version}) de {app_name} est disponible. - Mettre à jour le forfait - Deux façons de mettre à jour votre abonnement : Mettre à jour les informations du profil Votre nom d\'affichage et votre photo de profil sont visibles dans toutes les conversations. Accéder aux notes de mise à jour diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index c823b46604..cf6041168a 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -198,7 +198,6 @@ {app_name} heeft toegang tot de camera nodig om foto\'s en video\'s te maken of QR-codes te scannen. {app_name} heeft toegang tot de camera nodig om QR-codes te scannen Annuleren - Abonnement annuleren Wijzigen Wachtwoord wijzigen mislukt Wijzig je wachtwoord voor {app_name}. Lokaal opgeslagen gegevens worden opnieuw versleuteld met je nieuwe wachtwoord. @@ -306,7 +305,6 @@ Aanmaken Oproep starten Huidig wachtwoord - Huidig abonnement Knippen Donkere modus Weet u zeker dat u alle berichten, bijlagen en accountgegevens van dit apparaat wilt verwijderen en een nieuw account wilt aanmaken? @@ -783,7 +781,6 @@ Oké Aan Op je {device_type} apparaat - Open dit {app_name} account op een {device_type} apparaat waarop je bent aangemeld met het {platform_account} waarmee je je oorspronkelijk hebt geregistreerd. Wijzig vervolgens je abonnement via de instellingen van {app_pro}. Account aanmaken Account aangemaakt Ik heb een account @@ -886,7 +883,6 @@ Voorbeeldmelding Geactiveerd Alles is geregeld! - Je {app_pro} abonnement is bijgewerkt! Je wordt gefactureerd wanneer je huidige {pro} abonnement automatisch wordt verlengd op {date}. Je hebt al Upload nu GIF\'s en geanimeerde WebP-afbeeldingen voor je profielfoto! Krijg geanimeerde profielfoto\'s en ontgrendel premiumfuncties met {app_pro} @@ -907,11 +903,8 @@ Wil je langere berichten versturen? Verstuur meer tekst en ontgrendel premiumfuncties met {app_pro} Wil je meer vastzetten? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} - Je huidige abonnement is al met {percent}% korting ten opzichte van de volledige {app_pro} prijs. Verlopen - Helaas is je {pro} abonnement verlopen. Verleng om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. Verloopt binnenkort - Je {pro} abonnement verloopt over {time}. Werk je abonnement bij om toegang te blijven houden tot de exclusieve voordelen en functies van {app_pro}. {pro} FAQ Vind antwoorden op veelgestelde vragen in de {app_pro} FAQ. Upload GIF- en WebP-profielfoto\'s @@ -921,7 +914,6 @@ Onbeperkt gesprekken vastzetten Groep geactiveerd Deze groep heeft een grotere capaciteit! Er kunnen nu tot 300 leden deelnemen omdat een groepsbeheerder een - Het aanvragen van een terugbetaling is definitief. Indien goedgekeurd, wordt je {pro} abonnement onmiddellijk geannuleerd en verlies je de toegang tot alle {pro} functies. Verhoogde bijlagegrootte Verlengde berichtlengte Grotere groepen @@ -930,18 +922,6 @@ Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken. Dit bericht maakte gebruik van de volgende {app_pro}-functies: {percent}% korting - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd voor een nieuw {current_plan} op {date}. Wijzigingen aan je abonnement gaan in wanneer {pro} de volgende keer wordt verlengd. - Je {app_pro} abonnement is actief!\n\nJe abonnement wordt automatisch verlengd met een {current_plan} op {date}. - Je {app_pro} abonnement verloopt op {date}.\n\nWerk je abonnement nu bij om ononderbroken toegang te behouden tot exclusieve Pro functies. - Je {app_pro} abonnement verloopt op {date}. - {pro} abonnement niet gevonden - Er is geen actief abonnement gevonden voor je account. Als je denkt dat dit een vergissing is, neem dan contact op met de ondersteuning van {app_name} voor hulp. - {pro} abonnement herstellen - {pro} abonnement verlengen - Verleng je {app_pro} abonnement om opnieuw gebruik te maken van krachtige {app_pro} functies. - Je {app_pro} abonnement is verlengd! Bedankt voor je steun aan de {network_name}. - {pro} abonnement hersteld - Een geldig abonnement voor {app_pro} is gedetecteerd en je {pro} status is hersteld! 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand @@ -953,12 +933,9 @@ {pro} instellingen Je {pro} statistieken {pro} statistieken weerspiegelen het gebruik op dit apparaat en kunnen anders weergegeven worden op gekoppelde apparaten - Hulp nodig met je {pro} abonnement? Dien een verzoek in bij het ondersteuningsteam. Door bij te werken ga je akkoord met de {app_pro} Gebruiksvoorwaarden {icon} en het Privacybeleid {icon} Onbeperkte Pins Organiseer al je chats met onbeperkt vastgezette gesprekken. - Je zit momenteel op het {current_plan} abonnement. Weet je zeker dat je wilt overschakelen naar het {selected_plan} abonnement?\n\nAls je dit bijwerkt, wordt je abonnement op {date} automatisch verlengd met {selected_plan} {pro} toegang. - Je abonnement verloopt op {date}.\n\nDoor bij te werken wordt je abonnement automatisch verlengd op {date} voor een extra {selected_plan} aan Pro-toegang. Wil je meer uit {app_name} halen? Upgrade naar {app_pro} voor een krachtigere berichtbeleving. Profiel Toon afbeelding @@ -1136,8 +1113,6 @@ Vul alstublieft een kortere groepsnaam in Er is een nieuwe versie van {app_name} beschikbaar, tik om bij te werken Versie ({version}) van {app_name} is beschikbaar. - Abonnement bijwerken - Twee manieren om je abonnement bij te werken: Profielinformatie bijwerken Je weergavenaam en profielfoto zijn zichtbaar in alle gesprekken. Ga naar Release Opmerkingen diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index f84d6b2b93..9759ed9491 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -200,7 +200,6 @@ Aby robić zdjęcia, nagrywać filmy i skanować kody QR, aplikacja {app_name} potrzebuje dostępu do aparatu Aby skanować kody QR, aplikacja {app_name} wymaga dostępu do aparatu Anulowanie - Anuluj plan Zmień Nie udało się zmienić hasła Zmień hasło dla {app_name}. Dane przechowywane lokalnie zostaną ponownie zaszyfrowane nowym hasłem. @@ -310,7 +309,6 @@ Utwórz Tworzenie połączenia Obecne hasło - Obecny plan Wytnij Tryb ciemny Czy na pewno chcesz usunąć wszystkie wiadomości, załączniki i dane konta z tego urządzenia i utworzyć nowe konto? @@ -925,7 +923,6 @@ Podgląd powiadomień Aktywowano Wszystko gotowe! - Twój plan {app_pro} został zaktualizowany! Opłata zostanie pobrana, kiedy Twój obecny plan {pro} odnowi się automatycznie {date}. Masz już Możesz przesyłać GIF-y i animowane obrazy WebP jako swoje zdjęcie profilowe! Zyskaj animowane zdjęcia profilowe i odblokuj funkcje premium dzięki {app_pro} @@ -952,10 +949,7 @@ Chcesz wysyłać dłuższe wiadomości? Wyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} Chcesz przypinać więcej czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} - Cena Twojego obecnego planu jest obniżona o {percent}% pełnej ceny {app_pro}. - Niestety, Twój plan {pro} wygasł. Odnów go, by odzyskać dostęp do ekskluzywnych korzyści i funkcji {app_pro}. Niedługo wygaśnie - Twój plan {pro} wygasa za {time}. Zaktualizuj swój plan, aby zachować dostęp do ekskluzywnych korzyści i funkcji {app_pro}. FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. Prześlij obrazy profilowe w formacie GIF i WebP @@ -971,7 +965,6 @@ Ulepszono %1$s grup Ulepszono %1$s grup - Wniosek o zwrot jest ostateczny. Jeżeli zostanie zatwierdzony, Twój plan {pro} zostanie natychmiast anulowany i utracisz dostęp do wszystkich funkcji {pro}. Zwiększony rozmiar załączników Zwiększona długość wiadomości Większe grupy @@ -990,18 +983,6 @@ %1$s przypiętych konwersacji %1$s przypiętych konwersacji - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. Zmiany w Twoim planie wejdą w życie przy następnym odnowieniu subskrypcji {pro}. - Twój plan {app_pro} jest aktywny!\n\nZostanie on automatycznie odnowiony na kolejny {current_plan}, dnia {date}. - Twój plan {app_pro} wygasa {date}.\n\nZaktualizuj swój plan już teraz, by zapewnić sobie nieprzerwany dostęp do ekskluzywnych funkcji Pro. - Twój plan {app_pro} wygasa {date}. - Nie znaleziono planu {pro} - Nie znaleziono aktywnych planów dla Twojego konta. Jeżeli uważasz, że to błąd, prosimy o kontakt z Supportem {app_name}. - Odzyskaj plan {pro} - Odnów plan {pro} - Odnów swój plan {app_pro}, aby znów używać potężnych funkcji {app_pro} Beta. - Twój plan {app_pro} został odnowiony! Dziękujemy za wspieranie {network_name}. - Plan {pro} został odzyskany - Wykryto ważny plan {app_pro} oraz przywrócono Twój status {pro}! 1 miesiąc - {monthly_price} / miesiąc 3 miesiące - {monthly_price} / miesiąc 12 miesięcy - {monthly_price} / miesiąc @@ -1012,7 +993,6 @@ Ustawienia {pro} Twoje Statystyki {pro} Statystyki {pro} pokazują użycie na tym urządzeniu i mogą wyglądać różnie na połączonych urządzeniach - Potrzebujesz pomocy z planem {pro}? Wyślij zgłoszenie zespołowi wsparcia. Dokonując zmian wyrażasz zgodę na Warunki Świadczenia Usług {app_pro} {icon} oraz Politykę Prywatności {icon} Nielimitowane przypięcia Organizuj swoje czaty z nielimitowaną możliwością przypinania konwersacji. @@ -1199,8 +1179,6 @@ Wprowadź krótszy opis grupy Dostępna jest nowa wersja aplikacji {app_name}. Stuknij, aby zaktualizować Dostępna jest nowa wersja ({version}) aplikacji {app_name}. - Zaktualizuj plan - Dwa sposoby na aktualizację planu: Zaktualizuj informacje w profilu Twoja nazwa wyświetlana i obraz profilu są widoczne we wszystkich konwersacjach. Przejdź do informacji o wersji diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index e57ea49123..9c34c445c7 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -200,7 +200,6 @@ {app_name} потребує доступ до камери, щоб фотографувати, знімати відео або сканувати QR-коди. {app_name} потрібен дозвіл до камери для сканування QR-кодів Скасувати - Скасувати тарифний план Змінити Не вдалося змінити пароль Змінити ваш пароль для {app_name}. Локально збережені дані будуть наново шифровані з застосуванням нового паролю. @@ -310,7 +309,6 @@ Створити Викликаємо Поточний пароль - Поточна передплата Вирізати Темний режим Ви впевнені, що хочете видалити всі повідомлення, вкладення та дані облікового запису з цього пристрою та створити новий обліковий запис? @@ -932,7 +930,6 @@ Попередній перегляд сповіщень активовано Готово! - Твою підписку {app_pro} оновлено. {date} коли підписку {pro} буде подовжено, тоді й стягнуть гроші. У вас вже є Не зволікайте і завантажуйте GIF та анімовані WebP картинки для свого аватара! Отримайте анімовані аватари та розблокуйте преміальні функції з {app_pro} @@ -953,11 +950,8 @@ Хочете відправляти довші повідомлення? Надсилайте більше тексту та розблокуйте преміальні функції застосунку з Session Pro Потрібно більше закріплених бесід? Впорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro} - На поточну підписку ти вже маєш знижку {percent}% від загальної ціни {app_pro}. Підписка сплила - На жаль, підписка {pro} сплила. Онови її задля збереження переваг і можливостей {app_pro}. Невдовзі спливе підписка - Підписка {pro} спливе {time}. Онови підписку задля збереження переваг і можливостей {app_pro}. {pro} спливає за {time} {pro} ЧАП Відповіді на загальні запитання знайдеш у ЧаПи {app_pro}. @@ -968,7 +962,6 @@ Закріплюйте необмежену кількість бесід Групу активовано У цієї групи розширено можливості! Тепер вона може вміщати до 300 учасників, тому що адміністратор групи має - Вимагання повернення грошей закінчено. В разі схвалення твою підписку {pro} негайно скасують і ти втратиш всі можливості {pro}. Збільшений розмір вкладення Збільшена довжина повідомлень Більші групи @@ -977,15 +970,6 @@ Ви можете надсилати повідомлення до 10 000 символів у всіх розмовах. У цьому повідомленні наявні наступні функції Session Pro: {percent}% Знижки - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. Оновлення підписки настане під час наступного оновлення {pro}. - Для тебе діє підписка {app_pro}.\n\n{date} твою підписку буде самодійно поновлено як {current_plan}. - Твоя підписка {app_pro} спливе {date}.\n\nДля збереження особливих можливостей подовж свою підписку. - Підписка {app_pro} спливе {date}. - Передплата {pro} не знайдена - Відновити передплату {pro} - Оновити підписку {pro} - План {pro} відновлено - Виявлено дійсний план {app_pro}, та ваш статус {pro} було відновлено! 1 місяць — {monthly_price} / місяць 3 місяці — {monthly_price} / місяць 12 місяців – {monthly_price} / місяць @@ -996,11 +980,9 @@ Налаштування {pro} Ваша статистика {pro} Звіти підписки {pro} відображають використання лише цього пристрою, тож, мабуть, матимуть иншого вигляду на инших пристроях - Якщо потребуєш допомоги щодо підписки {pro}, надійшли звернення до відділу підтримки. Цією дією ти надаси згоду щодо дотримання Правил послуги {app_pro} {icon} і Ставлення до особистих відомостей {icon} Необмежена кількість закріплених бесід Закріплення необмеженої кількості співрозмовників в головному переліку. - Ваш план завершиться {date}.\n\nПісля оновлення налаштувань автоматичної оплати, ваш план автоматично подовжиться {date} на додатковий {selected_plan} доступу до Pro. Хочете отримати більше від {app_name}? Оновіться до {app_pro}, щоб мати потужніший досвід обміну повідомленнями. Профіль Аватар @@ -1190,8 +1172,6 @@ Будь ласка, введіть коротший опис групи Нова версія {app_name} доступна. Доступна нова версія ({version}) {app_name}. - Оновити тарифний план - Два шляхи поновлення твоєї підписки: Оновити інформацію облікового запису Ваше відображуване ім’я та зображення профілю видимі у всіх розмовах. Перейти в примітки до випуску diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4dc0a1c7d6..2ba2843a01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,10 +203,10 @@ {app_name} needs camera access to take photos and videos, or scan QR codes. {app_name} needs camera access to scan QR codes Cancel - Cancel Plan + Cancel {pro} Cancel {pro} Plan - Cancel your plan on the {platform} website, using the {platform_account} you signed up for {pro} with. - Cancel your plan on the {platform_store} website, using the {platform_account} you signed up for {pro} with. + Cancel on the {platform} website, using the {platform_account} you signed up for {pro} with. + Cancel on the {platform_store} website, using the {platform_account} you signed up for {pro} with. Change Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. @@ -318,8 +318,8 @@ Copy Create Creating Call + Current Billing Current Password - Current Plan Cut Dark Mode Are you sure you want to delete all messages, attachments, and account data from this device and create a new account? @@ -484,7 +484,7 @@ Copy Error and Quit Database Error Something went wrong. Please try again later. - Error loading {pro} plan + Error loading {pro} access An unknown error occurred. Failed to download Failures @@ -813,8 +813,8 @@ Okay On On your {device_type} device - Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel your plan via the {app_pro} settings. - Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, change your plan via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, cancel {pro} via the {app_pro} settings. + Open this {app_name} account on an {device_type} device logged into the {platform_account} you originally signed up with. Then, update your {pro} access via the {app_pro} settings. On a linked device On the {platform_store} website On the {platform} website @@ -921,9 +921,28 @@ Preferences Preview Preview Notification + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another {current_plan} on {date}. + Your {pro} access will expire on {date}.\n\nUpdate your {pro} access now to ensure you automatically renew before your {pro} access expires. + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another\n{current_plan} on {date}. Any updates you make here will take effect at your next renewal. + {pro} Access Error + Your {pro} access will expire on {date}. + {pro} Access Loading + Your {pro} access information is still being loaded. You cannot update until this process is complete. + {pro} access loading... + Unable to connect to the network to load your {pro} access information. Updating {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + {pro} Access Not Found + {app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance. + Recover {pro} Access + Renew {pro} Access + Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with. + Renew on the {platform} website using the {platform_account} you signed up for {pro} with. + Renew your {pro} access to start using powerful {app_pro} Beta features again. + {pro} Access Recovered + {app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored! + Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your {pro} access. Activated You\'re all set! - Your {app_pro} plan was updated! You will be billed when your current {pro} plan is automatically renewed on {date}. + Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}. You’ve already got Go ahead and upload GIFs and animated WebP images for your display picture! Get animated display pictures and unlock premium features with {app_pro} @@ -948,19 +967,19 @@ Want to send longer messages? Send more text and unlock premium features with {app_pro} Want more pins? Organize your chats and unlock premium features with {app_pro} Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} - We’re sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {app_pro} plan. + Sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {pro} access. Cancellation - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel your plan. - Two ways to cancel your plan: - Canceling your {app_pro} plan will prevent your plan from automatically renewing before your {pro} plan expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your plan expires. - Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} plan later. - Your current plan is already discounted by {percent}% of the full {app_pro} price. + Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. + Two ways to cancel your {pro} access: + Canceling {pro} access will prevent will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. + Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. + Your {pro} access is already discounted by {percent}% of the full {app_pro} price. Error refreshing {pro} status Expired - Unfortunately, your {pro} plan has expired. Renew to keep accessing the exclusive perks and features of {app_pro}. + Unfortunately, your {pro} access has expired. Renew to reactivate the exclusive perks and features of {app_pro}. Expiring Soon - Your {pro} plan is expiring in {time}. Update your plan to keep accessing the exclusive perks and features of {app_pro}. + Your {pro} access is expiring in {time}. Update now to keep accessing the exclusive perks and features of {app_pro}. {pro} expiring in {time} {pro} FAQ Find answers to common questions in the {app_pro} FAQ. @@ -975,7 +994,7 @@ %1$s Group Upgraded %1$s Groups Upgraded - Requesting a refund is final. If approved, your {pro} plan will be canceled immediately and you will lose access to all {pro} features. + Requesting a refund is final. If approved, your {pro} access will be canceled immediately and you will lose access to all {pro} features. Increased Attachment Size Increased Message Length Larger Groups @@ -989,37 +1008,17 @@ This message used the following {app_pro} features: With a new installation - Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew your plan in the {app_pro} settings. + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings. Currently, there are three ways to renew: {percent}% Off %1$s Pinned Conversation %1$s Pinned Conversations - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. Updates to your plan take effect when {pro} is next renewed. - Your {app_pro} plan is active!\n\nYour plan will automatically renew for another {current_plan} on {date}. - Your {app_pro} plan will expire on {date}.\n\nUpdate your plan now to ensure uninterrupted access to exclusive Pro features. - {pro} Plan Error - Your {app_pro} plan will expire on {date}. - {pro} Plan Loading - Information about your {pro} plan is still being loaded. You cannot update your plan until this process is complete. - {pro} plan loading... - Unable to connect to the network to load your current plan. Updating your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - {pro} Plan Not Found - No active plan was found for your account. If you believe this is a mistake, please reach out to {app_name} support for assistance. Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to request a refund. Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Recover {pro} Plan - Renew {pro} Plan - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} - Renew your plan in the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. - Renew your plan on the {platform_store} website using the {platform_account} you signed up for {pro} with. - Renew your plan on the {platform} website using the {platform_account} you signed up for {pro} with. - Renew your {app_pro} plan to start using powerful {app_pro} Beta features again. - Your {app_pro} plan has been renewed! Thank you for supporting the {network_name}. - {pro} Plan Restored - A valid plan for {app_pro} was detected and your {pro} status has been restored! - Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your plan. + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Your {app_pro} access has been renewed! Thank you for supporting the {network_name}. 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month 12 Months - {monthly_price} / Month @@ -1030,9 +1029,10 @@ Your refund request will be handled exclusively by {platform} through the {platform} website.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform} Refund Support Refunding {pro} - Refunds for {app_pro} plans are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. Renew {pro} Beta - Currently, {pro} plans can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew your plan here.\n\n{app_pro} developers are working hard on alternative payment options to allow users to purchase {pro} plans outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings @@ -1047,13 +1047,13 @@ {pro} status loading Unable to connect to the network to check your {pro} status. You cannot upgrade to {pro} until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to refresh your {pro} status. Some actions on this page will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - Unable to connect to the network to load your current plan. Renewing your plan via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. - Need help with your {pro} plan? Submit a request to the support team. + Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. + Need help with {pro}? Submit a request to the support team. By updating, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Unlimited Pins Organize all your chats with unlimited pinned conversations. - You are currently on the {current_plan} Plan. Are you sure you want to switch to the {selected_plan} Plan?\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of {pro} access. - Your plan will expire on {date}.\n\nBy updating, your plan will automatically renew on {date} for an additional {selected_plan} of Pro access. + Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. + Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. {platform} is processing your refund request Profile @@ -1111,7 +1111,7 @@ This is your recovery password. If you send it to someone they\'ll have full access to your account. Recreate Group Redo - Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your plan. + Because you originally signed up for {app_pro} via a different {platform_account}, you\'ll need to use that {platform_account} to update your {pro} access. Two ways to request a refund: Reduce message length by {count} @@ -1228,6 +1228,9 @@ Undo Unknown Unsupported CPU + Update + Update {pro} Access + Two ways to update your {pro} access: App updates Update Community Information Community name and description are visible to all community members @@ -1242,8 +1245,6 @@ Please enter a shorter group description A new version of {app_name} is available, tap to update A new version ({version}) of {app_name} is available. - Update Plan - Two ways to update your plan: Update Profile Information Your display name and display picture are visible in all conversations. Go to Release Notes @@ -1263,7 +1264,7 @@ Use Fast Mode Change your plan using the {platform_account} you used to sign up with, via the {platform} website . Via the {platform} website - Change your plan using the {platform_account} you used to sign up with, via the {platform_store} website . + Update your {pro} access using the {platform_account} you used to sign up with, via the {platform_store} website. Video Unable to play video. View From 80aa5cc9a1fe8f9cad84ad1aa24ca225e05da690 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 15 Oct 2025 14:16:41 +1100 Subject: [PATCH 045/219] Changed strings keys --- .../libsession/utilities/StringSubKeys.kt | 2 +- .../securesms/home/HomeDialogs.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 20 +++++++--- .../prosettings/ProSettingsViewModel.kt | 39 +++++++++---------- .../chooseplan/ChoosePlanNoBilling.kt | 5 ++- .../chooseplan/ChoosePlanNonOriginating.kt | 20 ++++++---- .../chooseplan/ChoosePlanScreen.kt | 21 +++++----- 7 files changed, 63 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 6c5d8e695f..3d79dddea0 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -51,7 +51,7 @@ object StringSubstitutionConstants { const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" - const val SELECTED_PLAN_KEY: StringSubKey = "selected_plan" + const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length" const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other" diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index c27193316c..45ab8a9a8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -86,7 +86,7 @@ fun HomeDialogs( CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), ), - positiveButtonText = stringResource(R.string.updatePlan), + positiveButtonText = stringResource(R.string.update), negativeButtonText = stringResource(R.string.close), onUpgrade = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index f6777d27d5..00a14c9ad2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -518,7 +518,7 @@ fun ProSettings( val (subtitle, subColor, icon) = when(subscriptionRefreshState){ is State.Loading -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.proPlanLoadingEllipsis) + Phrase.from(LocalContext.current, R.string.proAccessLoadingEllipsis) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.text, @@ -526,7 +526,7 @@ fun ProSettings( ) is State.Error -> Triple Unit>( - Phrase.from(LocalContext.current, R.string.errorLoadingProPlan) + Phrase.from(LocalContext.current, R.string.errorLoadingProAccess) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.warning, chevronIcon @@ -539,7 +539,11 @@ fun ProSettings( } ActionRowItem( - title = annotatedStringResource(R.string.updatePlan), + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), subtitle = annotatedStringResource(subtitle), subtitleColor = subColor, endContent = { @@ -766,7 +770,11 @@ fun ProManage( when(data){ is SubscriptionType.Active.AutoRenewing -> { IconActionRowItem( - title = annotatedStringResource(R.string.cancelPlan), + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.cancelAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), titleColor = LocalColors.current.danger, icon = R.drawable.ic_circle_x_custom, iconColor = LocalColors.current.danger, @@ -821,7 +829,7 @@ fun ProManage( ActionRowItem( title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proPlanRenew) + Phrase.from(LocalContext.current, R.string.proAccessRenew) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), @@ -841,7 +849,7 @@ fun ProManage( Divider() IconActionRowItem( title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proPlanRecover) + Phrase.from(LocalContext.current, R.string.proAccessRecover) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 3791ee6bec..78a7bdfc40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import android.icu.util.MeasureUnit +import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -22,13 +23,12 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PERCENT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager @@ -102,7 +102,7 @@ class ProSettingsViewModel @Inject constructor( badges = buildList { if(currentPlan12Months){ add( - ProPlanBadge(context.getString(R.string.currentPlan)) + ProPlanBadge(context.getString(R.string.currentBilling)) ) } @@ -131,7 +131,7 @@ class ProSettingsViewModel @Inject constructor( badges = buildList { if(currentPlan3Months){ add( - ProPlanBadge(context.getString(R.string.currentPlan)) + ProPlanBadge(context.getString(R.string.currentBilling)) ) } @@ -158,7 +158,7 @@ class ProSettingsViewModel @Inject constructor( currentPlan = currentPlan1Month, durationType = ProSubscriptionDuration.ONE_MONTH, badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentPlan)) + ProPlanBadge(context.getString(R.string.currentBilling)) ) else emptyList(), ), ) @@ -241,10 +241,10 @@ class ProSettingsViewModel @Inject constructor( // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proPlanLoading)) + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to - Phrase.from(context.getText(R.string.proPlanLoadingDescription)) + Phrase.from(context.getText(R.string.proAccessLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() else -> Phrase.from(context.getText(R.string.checkingProStatus)) @@ -270,10 +270,11 @@ class ProSettingsViewModel @Inject constructor( is State.Error -> { val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proPlanError)) + is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to - Phrase.from(context.getText(R.string.proPlanNetworkLoadError)) + Phrase.from(context.getText(R.string.proAccessNetworkLoadError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() else -> Phrase.from(context.getText(R.string.proStatusError)) @@ -394,21 +395,19 @@ class ProSettingsViewModel @Inject constructor( _dialogState.update { it.copy( showSimpleDialog = SimpleDialogData( - title = context.getString(R.string.updatePlan), + title = Phrase.from(context, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), message = if(currentSubscription is SubscriptionType.Active.AutoRenewing) - Phrase.from(context.getText(R.string.proUpdatePlanDescription)) - .put(CURRENT_PLAN_KEY, currentSubscriptionDuration) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) - .put(DATE_KEY, newSubscriptionExpiryString) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) + Phrase.from(context.getText(R.string.proUpdateAccessDescription)) + //todo PRO STRING need to correct keys here - still waiting on answers from the team .format() - else Phrase.from(context.getText(R.string.proUpdatePlanExpireDescription)) - .put(DATE_KEY, newSubscriptionExpiryString) + else Phrase.from(context.getText(R.string.proUpdateAccessExpireDescription)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, newSubscriptionExpiryString) - .put(SELECTED_PLAN_KEY, selectedSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) .format(), - positiveText = context.getString(R.string.updatePlan), + positiveText = context.getString(R.string.update), negativeText = context.getString(R.string.cancel), positiveStyleDanger = false, onPositive = { getPlanFromProvider() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index b6b23271b0..f95dc2e229 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -49,7 +49,8 @@ fun ChoosePlanNoBilling( val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE val headerTitle = when(subscription) { - is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proPlanRenewStart)) + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() @@ -125,7 +126,7 @@ fun ChoosePlanNoBilling( title = Phrase.from(context.getText(R.string.onPlatformStoreWebsite)) .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .format(), - info = Phrase.from(context.getText(R.string.proPlanRenewPlatformStoreWebsite)) + info = Phrase.from(context.getText(R.string.proAccessRenewPlatformStoreWebsite)) .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.getPlatformDisplayName()) .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(PRO_KEY, NonTranslatableStringConstants.PRO) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index c9658dd1a4..035516acff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -19,6 +19,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_ import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_STORE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.BaseNonOriginatingProSettingsScreen import org.thoughtcrime.securesms.preferences.prosettings.NonOriginatingLinkCellData @@ -47,13 +48,13 @@ fun ChoosePlanNonOriginating( val platformOverride = subscription.subscriptionDetails.getPlatformDisplayName() val headerTitle = when(subscription) { - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proPlanExpireDate)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessExpireDate)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, subscription.duration.expiryFromNow()) .format() - is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proPlanActivatedAutoShort)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatedAutoShort)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( context = context, amount = subscription.duration.duration.months, @@ -76,13 +77,18 @@ fun ChoosePlanNonOriginating( onButtonClick = { sendCommand(ShowOpenUrlDialog(subscription.subscriptionDetails.subscriptionUrl)) }, - contentTitle = stringResource(R.string.updatePlan), - contentDescription = Phrase.from(context.getText(R.string.proPlanSignUp)) + contentTitle = Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), + contentDescription = Phrase.from(context.getText(R.string.proAccessSignUp)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PLATFORM_STORE_KEY, subscription.subscriptionDetails.store) .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), - linkCellsInfo = stringResource(R.string.updatePlanTwo), + linkCellsInfo = Phrase.from(context.getText(R.string.updateAccessTwo)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString(), linkCells = listOf( NonOriginatingLinkCellData( title = Phrase.from(context.getText(R.string.onDevice)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index be7e0b4d91..47c24508df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -103,18 +103,18 @@ fun ChoosePlan( val context = LocalContext.current val title = when (planData.subscriptionType) { is SubscriptionType.Expired -> - Phrase.from(context.getText(R.string.proPlanRenewStart)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proPlanActivatedNotAuto)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessActivatedNotAuto)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) .format() - is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proPlanActivatedAuto)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatesAuto)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put( CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( context = context, @@ -123,7 +123,6 @@ fun ChoosePlan( ) ) .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() //todo PRO cater for brand new subscription in here @@ -169,8 +168,12 @@ fun ChoosePlan( val buttonLabel = when (planData.subscriptionType) { is SubscriptionType.Expired -> context.getString(R.string.renew) - is SubscriptionType.Active.Expiring -> context.getString(R.string.updatePlan) - else -> context.getString(R.string.updatePlan) + is SubscriptionType.Active.Expiring -> Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + else -> Phrase.from(LocalContext.current, R.string.updateAccess) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() } AccentFillButtonRect( From ae7d1f6ecae4449e593505946a6ef93a2ee7db28 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 15 Oct 2025 15:44:11 +1100 Subject: [PATCH 046/219] Fixing up new string keys --- .../session/libsession/utilities/StringSubKeys.kt | 2 ++ .../prosettings/CancelPlanNonOriginating.kt | 6 +----- .../preferences/prosettings/CancelPlanScreen.kt | 1 - .../prosettings/ProSettingsViewModel.kt | 14 +++++++++++--- .../prosettings/chooseplan/ChoosePlanNoBilling.kt | 3 ++- .../chooseplan/ChoosePlanNonOriginating.kt | 2 ++ .../thoughtcrime/securesms/pro/ProStatusManager.kt | 2 +- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 3d79dddea0..7d6a2bbb45 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -51,7 +51,9 @@ object StringSubstitutionConstants { const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" + const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length" const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length" + const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular" const val PLATFORM_KEY: StringSubKey = "platform" const val PLATFORM_STORE_KEY: StringSubKey = "platform_store" const val PLATFORM_STORE2_KEY: StringSubKey = "platform_store_other" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index 740c8c3a7a..792c8957bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -36,7 +36,6 @@ fun CancelPlanNonOriginating( onBack = onBack, headerTitle = Phrase.from(context.getText(R.string.proCancelSorry)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), buttonText = Phrase.from(context.getText(R.string.openPlatformWebsite)) .put(PLATFORM_KEY, subscriptionDetails.getPlatformDisplayName()) @@ -47,12 +46,8 @@ fun CancelPlanNonOriginating( }, contentTitle = stringResource(R.string.proCancellation), contentDescription = Phrase.from(context.getText(R.string.proCancellationDescription)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .format(), linkCellsInfo = stringResource(R.string.proCancellationOptions), @@ -66,6 +61,7 @@ fun CancelPlanNonOriginating( .put(DEVICE_TYPE_KEY, subscriptionDetails.device) .put(PLATFORM_ACCOUNT_KEY, subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_smartphone ), diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index 1133073c98..b16d5abd08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -97,7 +97,6 @@ fun CancelPlan( }, title = Phrase.from(context.getText(R.string.proCancelSorry)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), ){ Column { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 78a7bdfc40..3464985b08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import android.icu.util.MeasureUnit -import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -23,12 +22,14 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PERCENT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRICE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager @@ -96,7 +97,7 @@ class ProSettingsViewModel @Inject constructor( subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) .put(PRICE_KEY, "$47.99") //todo PRO calculate properly .format().toString(), - selected = currentPlan12Months, + selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew currentPlan = currentPlan12Months, durationType = ProSubscriptionDuration.TWELVE_MONTHS, badges = buildList { @@ -110,6 +111,7 @@ class ProSettingsViewModel @Inject constructor( ProPlanBadge( "33% Off", //todo PRO calculate properly if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PERCENT_KEY, "33") //todo PRO calculate properly .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString() @@ -139,6 +141,7 @@ class ProSettingsViewModel @Inject constructor( ProPlanBadge( "16% Off", //todo PRO calculate properly if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PERCENT_KEY, "16") //todo PRO calculate properly .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString() @@ -400,7 +403,12 @@ class ProSettingsViewModel @Inject constructor( .format().toString(), message = if(currentSubscription is SubscriptionType.Active.AutoRenewing) Phrase.from(context.getText(R.string.proUpdateAccessDescription)) - //todo PRO STRING need to correct keys here - still waiting on answers from the team + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, newSubscriptionExpiryString) + .put(CURRENT_PLAN_LENGTH_KEY, currentSubscriptionDuration) + .put(SELECTED_PLAN_LENGTH_KEY, selectedSubscriptionDuration) + // for this string below, we want to remove the 's' at the end if there is one: 12 Months becomes 12 Month + .put(SELECTED_PLAN_LENGTH_SINGULAR_KEY, selectedSubscriptionDuration.removeSuffix("s")) .format() else Phrase.from(context.getText(R.string.proUpdateAccessExpireDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index f95dc2e229..9faf5bb2ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -96,7 +96,8 @@ fun ChoosePlanNoBilling( add( NonOriginatingLinkCellData( title = stringResource(R.string.onLinkedDevice), - info = Phrase.from(context.getText(R.string.proPlanRenewDesktopLinked)) + info = Phrase.from(context.getText(R.string.proRenewDesktopLinked)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(PLATFORM_STORE_KEY, defaultGoogleStore) .put(PLATFORM_STORE2_KEY, defaultAppleStore) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index 035516acff..c8fcf64616 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -99,6 +99,7 @@ fun ChoosePlanNonOriginating( .put(DEVICE_TYPE_KEY, subscription.subscriptionDetails.device) .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_smartphone ), @@ -109,6 +110,7 @@ fun ChoosePlanNonOriginating( info = Phrase.from(context.getText(R.string.viaStoreWebsiteDescription)) .put(PLATFORM_ACCOUNT_KEY, subscription.subscriptionDetails.platformAccount) .put(PLATFORM_STORE_KEY, platformOverride) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_globe ) 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 10e3ae5646..21b6159300 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -137,7 +137,7 @@ class ProStatusManager @Inject constructor( ) }, // SubscriptionType.NeverSubscribed, - refreshState = State.Error(Exception()), + refreshState = State.Success(Unit), ) }.stateIn(GlobalScope, SharingStarted.Eagerly, From 8d219d992a2c224cae0d838f2f9691b4db133e0c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 15 Oct 2025 15:52:29 +1100 Subject: [PATCH 047/219] Fixing strings with new keys --- .../preferences/prosettings/BaseProSettingsScreens.kt | 4 ++++ .../preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt | 2 +- .../pro/subscription/PlayStoreSubscriptionManager.kt | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index f8a056a3c8..71cf886ad2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api @@ -95,6 +96,9 @@ fun BaseProSettingsScreen( ) content() + + Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 9faf5bb2ec..1ddf166c35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -75,7 +75,6 @@ fun ChoosePlanNoBilling( "huawei" -> "Huawei App Gallery" else -> "APK" }) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(PLATFORM_STORE_KEY, defaultGoogleStore) .put(PLATFORM_STORE2_KEY, defaultAppleStore) @@ -115,6 +114,7 @@ fun ChoosePlanNoBilling( .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) .put(PLATFORM_STORE_KEY, defaultGoogleStore) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format(), iconRes = R.drawable.ic_smartphone ) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index db616eecc1..e4023db77a 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -33,7 +33,7 @@ class PlayStoreSubscriptionManager @Inject constructor( override val description = "" override val iconRes = null - override val supportsBilling: Boolean = true + override val supportsBilling: Boolean = false override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From abcaafbefa9a0ecf8d1890fa5a51d4b980b5678b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 15 Oct 2025 16:30:17 +1100 Subject: [PATCH 048/219] New debug drop down to force the pro status (normal, always loading, always erroring) --- .../utilities/TextSecurePreferences.kt | 14 ++++++++++++ .../securesms/debugmenu/DebugMenu.kt | 22 +++++++++++++++++++ .../securesms/debugmenu/DebugMenuViewModel.kt | 22 +++++++++++++++++++ .../securesms/pro/ProStatusManager.kt | 17 ++++++++++---- 4 files changed, 71 insertions(+), 4 deletions(-) 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 ef3304e4f6..4dfe9bcb29 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -216,6 +216,8 @@ interface TextSecurePreferences { fun getDebugSubscriptionType(): DebugMenuViewModel.DebugSubscriptionStatus? fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?) + fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? + fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? @@ -377,6 +379,7 @@ interface TextSecurePreferences { const val DEBUG_MESSAGE_FEATURES = "debug_message_features" const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" + const val DEBUG_PRO_PLAN_STATUS = "debug_pro_plan_status" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" @@ -1766,6 +1769,17 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS) } + override fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? { + return getStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, null)?.let { + DebugMenuViewModel.DebugProPlanStatus.valueOf(it) + } + } + + override fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) { + setStringPreference(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS, status?.name) + _events.tryEmit(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS) + } + override fun getSubscriptionProvider(): String? { return getStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, null) } 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 e3ed976a0c..c874e1df2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -277,6 +277,26 @@ fun DebugMenu( ) } ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + text = "Pro Data Status", + style = LocalType.current.base + ) + DropDown( + modifier = Modifier.fillMaxWidth() + .padding(top = LocalDimensions.current.xxsSpacing), + selectedText = uiState.selectedDebugProPlanStatus.label, + values = uiState.debugProPlanStatus.map { it.label }, + onValueSelected = { selection -> + sendCommand( + DebugMenuViewModel.Commands.SetDebugProPlanStatus( + uiState.debugProPlanStatus.first { it.label == selection } + ) + ) + } + ) } } @@ -750,6 +770,8 @@ fun PreviewDebugMenu() { dbInspectorState = DebugMenuViewModel.DatabaseInspectorState.STARTED, debugSubscriptionStatuses = setOf(DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE), selectedDebugSubscriptionStatus = DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlanStatus = setOf(DebugMenuViewModel.DebugProPlanStatus.NORMAL), + selectedDebugProPlanStatus = DebugMenuViewModel.DebugProPlanStatus.NORMAL, debugProPlans = emptyList(), ), sendCommand = {}, 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 fc58daca2f..5dc37e077a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -93,6 +93,12 @@ class DebugMenuViewModel @Inject constructor( DebugSubscriptionStatus.EXPIRED_APPLE, ), selectedDebugSubscriptionStatus = textSecurePreferences.getDebugSubscriptionType() ?: DebugSubscriptionStatus.AUTO_GOOGLE, + debugProPlanStatus = setOf( + DebugProPlanStatus.NORMAL, + DebugProPlanStatus.LOADING, + DebugProPlanStatus.ERROR, + ), + selectedDebugProPlanStatus = textSecurePreferences.getDebugProPlanStatus() ?: DebugProPlanStatus.NORMAL, debugProPlans = subscriptionManagers.asSequence() .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), @@ -302,6 +308,13 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.SetDebugProPlanStatus -> { + textSecurePreferences.setDebugProPlanStatus(command.status) + _uiState.update { + it.copy(selectedDebugProPlanStatus = command.status) + } + } + is Commands.PurchaseDebugPlan -> { command.plan.apply { manager.purchasePlan(plan) } } @@ -410,6 +423,8 @@ class DebugMenuViewModel @Inject constructor( val dbInspectorState: DatabaseInspectorState, val debugSubscriptionStatuses: Set, val selectedDebugSubscriptionStatus: DebugSubscriptionStatus, + val debugProPlanStatus: Set, + val selectedDebugProPlanStatus: DebugProPlanStatus, val debugProPlans: List, ) @@ -428,6 +443,12 @@ class DebugMenuViewModel @Inject constructor( EXPIRED_APPLE("Expired (Apple)"), } + enum class DebugProPlanStatus(val label: String){ + NORMAL("Normal State"), + LOADING("Always Loading"), + ERROR("Always Erroring out"), + } + sealed class Commands { object ChangeEnvironment : Commands() data class ShowEnvironmentWarningDialog(val environment: String) : Commands() @@ -452,6 +473,7 @@ class DebugMenuViewModel @Inject constructor( data class GenerateContacts(val prefix: String, val count: Int): Commands() data object ToggleDatabaseInspector : Commands() data class SetDebugSubscriptionStatus(val status: DebugSubscriptionStatus) : Commands() + data class SetDebugProPlanStatus(val status: DebugProPlanStatus) : Commands() data class PurchaseDebugPlan(val plan: DebugProPlan) : Commands() } } \ No newline at end of file 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 21b6159300..f6a01687a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -42,11 +42,20 @@ class ProStatusManager @Inject constructor( recipientRepository.observeSelf(), (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS } as Flow<*>) .onStart { emit(Unit) } - .map { prefs.getDebugSubscriptionType() } - ){ selfRecipient, debugSubscription -> + .map { prefs.getDebugSubscriptionType() }, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_PRO_PLAN_STATUS } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.getDebugProPlanStatus() }, + ){ selfRecipient, debugSubscription, debugProPlanStatus -> //todo PRO implement properly val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE + val proStatus = when(debugProPlanStatus){ + DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading + DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) + else -> State.Success(Unit) + } + SubscriptionState( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> SubscriptionType.Active.AutoRenewing( @@ -136,8 +145,8 @@ class ProStatusManager @Inject constructor( ) ) }, - // SubscriptionType.NeverSubscribed, - refreshState = State.Success(Unit), + + refreshState = proStatus, ) }.stateIn(GlobalScope, SharingStarted.Eagerly, From ed6299ef78e76586b313094c4d3d5821ae1722f0 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Mon, 20 Oct 2025 04:34:21 +0000 Subject: [PATCH 049/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+cs+CZ/strings.xml | 1 - app/src/main/res/values/strings.xml | 32 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 81f8891675..7f28211f45 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -992,7 +992,6 @@ %1$s delších zpráv odesláno V této zprávě byly použity následující funkce {app_pro}: - Nyní jsou k dispozici tři způsoby obnovy: Sleva {percent} % %1$s připnutá konverzace diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ba2843a01..f1fce46ec2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -211,6 +211,7 @@ Failed to change password Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. Checking {pro} Status + Checking your {pro} status. You\'ll be able to continue once this check is complete. Checking your {pro} details. Some information on this page may be unavailable until this check is complete. Checking {pro} Status... Checking your {pro} details. You cannot renew until this check is complete. @@ -763,6 +764,8 @@ Set Nickname No No Suggestions + Send messages up to 10,000 characters in all conversations. + Organize chats with unlimited pinned conversations. None Not now Note to Self @@ -921,9 +924,9 @@ Preferences Preview Preview Notification - Your {pro} access is active!\n\nYour {pro} access will automatically renew for another {current_plan} on {date}. + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another {current_plan_length} on {date}. Your {pro} access will expire on {date}.\n\nUpdate your {pro} access now to ensure you automatically renew before your {pro} access expires. - Your {pro} access is active!\n\nYour {pro} access will automatically renew for another\n{current_plan} on {date}. Any updates you make here will take effect at your next renewal. + Your {pro} access is active!\n\nYour {pro} access will automatically renew for another\n{current_plan_length} on {date}. Any updates you make here will take effect at your next renewal. {pro} Access Error Your {pro} access will expire on {date}. {pro} Access Loading @@ -934,12 +937,14 @@ {app_name} detected that your account does not have {pro} access. If you believe this is a mistake, please reach out to {app_name} support for assistance. Recover {pro} Access Renew {pro} Access + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Renew your {pro} access on the {platform_store} website using the {platform_account} you signed up for {pro} with. Renew on the {platform} website using the {platform_account} you signed up for {pro} with. Renew your {pro} access to start using powerful {app_pro} Beta features again. {pro} Access Recovered {app_name} detected and recovered {pro} access for your account. Your {pro} status has been restored! Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to update your {pro} access. + Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Activated You\'re all set! Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}. @@ -972,6 +977,7 @@ Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. Two ways to cancel your {pro} access: Canceling {pro} access will prevent will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. + Choose the {pro} access option that\'s right for you.\n\nLonger access means bigger discounts. Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. Your {pro} access is already discounted by {percent}% of the full {app_pro} price. @@ -988,6 +994,7 @@ Plus loads more exclusive features Messages up to 10,000 characters Pin unlimited conversations + Want to use {app_name} to its fullest potential?\n\nUpgrade to {app_pro} Beta to get access to loads of exclusive perks and features. Group Activated This group has expanded capacity! It can support up to 300 members because a group admin has @@ -1009,7 +1016,9 @@ This message used the following {app_pro} features: With a new installation Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and renew {pro} from the {app_pro} settings. - Currently, there are three ways to renew: + Reinstall {app_name} on this device via the {platform_store}, restore your account with your Recovery Password, and upgrade to {pro} from the {app_pro} settings. + For now, there are three ways to renew: + For now, there are two ways to renew: {percent}% Off %1$s Pinned Conversation @@ -1017,7 +1026,6 @@ Because you originally signed up for {app_pro} via the {platform_store}, you\'ll need to use your {platform_account} to request a refund. Because you originally signed up for {app_pro} via the {platform_store}, your refund request will be processed by {app_name} Support.\n\nRequest a refund by hitting the button below and completing the refund request form.\n\nWhile {app_name} Support strives to process refund requests within 24-72 hours, processing may take longer during times of high request volume. - Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you are using {app_name} Desktop, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Your {app_pro} access has been renewed! Thank you for supporting the {network_name}. 1 Month - {monthly_price} / Month 3 Months - {monthly_price} / Month @@ -1030,12 +1038,19 @@ Please contact {platform} for further updates on your refund request. Due to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests.\n\n{platform} Refund Support Refunding {pro} Refunds for {app_pro} are handled exclusively by {platform} through the {platform_store}.\n\nDue to {platform} refund policies, {app_name} developers have no ability to influence the outcome of refund requests. This includes whether the request is approved or denied, as well as whether a full or partial refund is issued. + Want to use animated display pictures again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Renew {pro} Beta Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Want to send longer messages again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to use {app_name} to its max potential again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to pin more than 5 conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to more pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with {pro} Settings + Start Using {pro} Your {pro} Stats {pro} Stats Loading Your {pro} stats are loading, please wait. @@ -1045,6 +1060,7 @@ {pro} Status Loading Your {pro} information is being loaded. Some actions on this page may be unavailable until loading is complete. {pro} status loading + Unable to connect to the network to check your {pro} status. You cannot continue until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to check your {pro} status. You cannot upgrade to {pro} until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to refresh your {pro} status. Some actions on this page will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. Unable to connect to the network to load your current {pro} access. Renewing {pro} via {app_name} will be disabled until connectivity is restored.\n\nPlease check your network connection and retry. @@ -1054,6 +1070,14 @@ Organize all your chats with unlimited pinned conversations. Your current billing option grants {current_plan_length} of {pro} access. Are you sure you want to switch to the {selected_plan_length_singular} billing option?\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. + Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features. + Upgrade to {pro} from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. + Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + For now, there is only one way to upgrade: + For now, there are two ways to upgrade: + You have upgraded to {app_pro}!\n\nThank you for supporting the {network_name}. + Upgrading to {pro} + By upgrading, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. {platform} is processing your refund request Profile From 80e8cc06b15b59cdd8fd58348aa82d7bd8fc3e87 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 20 Oct 2025 16:34:47 +1100 Subject: [PATCH 050/219] Non pro user screens: no billing choose plan --- .../prosettings/ProSettingsHomeScreen.kt | 25 +++++- .../chooseplan/ChoosePlanNoBilling.kt | 90 +++++++++++++------ .../securesms/pro/ProStatusManager.kt | 12 ++- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 00a14c9ad2..6cd615cbf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -66,6 +67,7 @@ import org.thoughtcrime.securesms.ui.IconActionRowItem import org.thoughtcrime.securesms.ui.ProBadgeText import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.SwitchActionRowItem +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.ExtraSmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -74,6 +76,7 @@ import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.proBadgeColorDisabled import org.thoughtcrime.securesms.ui.proBadgeColorStandard import org.thoughtcrime.securesms.ui.qaTag +import org.thoughtcrime.securesms.ui.shimmerOverlay import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -184,6 +187,26 @@ fun ProSettingsHome( } } ) { + // Header for non-pro users + if(subscriptionType is SubscriptionType.NeverSubscribed) { + Text( + text = Phrase.from(context.getText(R.string.proFullestPotential)) + .put(APP_NAME_KEY, stringResource(R.string.app_name)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString(), + style = LocalType.current.base, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(LocalDimensions.current.spacing)) + + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.theContinue), + onClick = { sendCommand(GoToChoosePlan) } + ) + } + // Pro Stats if(subscriptionType is SubscriptionType.Active){ Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -652,7 +675,7 @@ fun ProFeatures( // More... ProFeatureItem( - title = stringResource(R.string.proFeatureListLoadsMore), + title = stringResource(R.string.plusLoadsMore), subtitle = annotatedStringResource( text = Phrase.from(LocalContext.current.getText(R.string.plusLoadsMoreDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index 1ddf166c35..bc16f1a34d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -43,8 +43,6 @@ fun ChoosePlanNoBilling( ){ val context = LocalContext.current - //todo PRO cater for NEVER SUBSCRIBED here - val defaultGoogleStore = ProStatusManager.DEFAULT_GOOGLE_STORE val defaultAppleStore = ProStatusManager.DEFAULT_APPLE_STORE @@ -54,6 +52,10 @@ fun ChoosePlanNoBilling( .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeAccess)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + else -> "" } @@ -61,6 +63,11 @@ fun ChoosePlanNoBilling( is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.renewingPro)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() + + is SubscriptionType.NeverSubscribed-> Phrase.from(context.getText(R.string.proUpgradingTo)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + else -> "" } @@ -82,11 +89,63 @@ fun ChoosePlanNoBilling( .put(ICON_KEY, iconExternalLink) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeNoAccessBilling)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(BUILD_VARIANT_KEY, when (BuildConfig.FLAVOR) { + "fdroid" -> "F-Droid Store" + "huawei" -> "Huawei App Gallery" + else -> "APK" + }) + .put(ICON_KEY, iconExternalLink) + .format() + else -> "" } val cellsInfo = when(subscription) { is SubscriptionType.Expired -> stringResource(R.string.proOptionsRenewalSubtitle) + is SubscriptionType.NeverSubscribed -> stringResource(R.string.proUpgradeOptionsTwo) + else -> "" + } + + val cell1Text: CharSequence = when(subscription) { + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proRenewDesktopLinked)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proUpgradeDesktopLinked)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(PLATFORM_STORE2_KEY, defaultAppleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + + else -> "" + } + + val cell2Text: CharSequence = when(subscription) { + is SubscriptionType.Expired -> Phrase.from(context.getText(R.string.proNewInstallationDescription)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proNewInstallationUpgrade)) + .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) + .put(PLATFORM_STORE_KEY, defaultGoogleStore) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + else -> "" } @@ -95,13 +154,7 @@ fun ChoosePlanNoBilling( add( NonOriginatingLinkCellData( title = stringResource(R.string.onLinkedDevice), - info = Phrase.from(context.getText(R.string.proRenewDesktopLinked)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(PLATFORM_STORE2_KEY, defaultAppleStore) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format(), + info = cell1Text, iconRes = R.drawable.ic_link ) ) @@ -110,12 +163,7 @@ fun ChoosePlanNoBilling( add( NonOriginatingLinkCellData( title = stringResource(R.string.proNewInstallation), - info = Phrase.from(context.getText(R.string.proNewInstallationDescription)) - .put(APP_NAME_KEY, NonTranslatableStringConstants.APP_NAME) - .put(PLATFORM_STORE_KEY, defaultGoogleStore) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), + info = cell2Text, iconRes = R.drawable.ic_smartphone ) ) @@ -194,17 +242,7 @@ private fun PreviewNoBiilingBrandNewPlan( PreviewTheme(colors) { val context = LocalContext.current ChoosePlanNoBilling ( - subscription = SubscriptionType.Expired( - expiredAt = Instant.now() - Duration.ofDays(14), - SubscriptionDetails( - device = "iPhone", - store = "Apple App Store", - platform = "Apple", - platformAccount = "Apple Account", - subscriptionUrl = "https://www.apple.com/account/subscriptions", - refundUrl = "https://www.apple.com/account/subscriptions", - ) - ), + subscription = SubscriptionType.NeverSubscribed, sendCommand = {}, onBack = {}, ) 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 f6a01687a2..ba363f5d9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -50,13 +50,19 @@ class ProStatusManager @Inject constructor( //todo PRO implement properly val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE - val proStatus = when(debugProPlanStatus){ + val proDataStatus = when(debugProPlanStatus){ DebugMenuViewModel.DebugProPlanStatus.LOADING -> State.Loading DebugMenuViewModel.DebugProPlanStatus.ERROR -> State.Error(Exception()) else -> State.Success(Unit) } - SubscriptionState( + if(selfRecipient.proStatus is ProStatus.None){ + SubscriptionState( + type = SubscriptionType.NeverSubscribed, + refreshState = proDataStatus + ) + } + else SubscriptionState( type = when(subscriptionState){ DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE -> SubscriptionType.Active.AutoRenewing( proStatus = ProStatus.Pro( @@ -146,7 +152,7 @@ class ProStatusManager @Inject constructor( ) }, - refreshState = proStatus, + refreshState = proDataStatus, ) }.stateIn(GlobalScope, SharingStarted.Eagerly, From e29b6fe30db9c3facba641bd8469cfa094df99e1 Mon Sep 17 00:00:00 2001 From: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Date: Mon, 20 Oct 2025 06:04:04 +0000 Subject: [PATCH 051/219] [Automated] Update translations from Crowdin --- app/src/main/res/values/strings.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1fce46ec2..27c0b3dbc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,7 +977,7 @@ Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. Two ways to cancel your {pro} access: Canceling {pro} access will prevent will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. - Choose the {pro} access option that\'s right for you.\n\nLonger access means bigger discounts. + Choose the {pro} access option that\'s right for you.\nLonger access means bigger discounts. Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. Your {pro} access is already discounted by {percent}% of the full {app_pro} price. @@ -994,7 +994,7 @@ Plus loads more exclusive features Messages up to 10,000 characters Pin unlimited conversations - Want to use {app_name} to its fullest potential?\n\nUpgrade to {app_pro} Beta to get access to loads of exclusive perks and features. + Want to use {app_name} to its fullest potential?\nUpgrade to {app_pro} Beta to get access to loads of exclusive perks and features. Group Activated This group has expanded capacity! It can support up to 300 members because a group admin has @@ -1072,7 +1072,7 @@ Your {pro} access will expire on {date}.\n\nBy updating, your {pro} access will automatically renew on {date} for an additional {selected_plan_length} of {pro} access. Upgrade to {app_pro} Beta to get access to loads of exclusive perks and features. Upgrade to {pro} from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. - Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} + Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} For now, there is only one way to upgrade: For now, there are two ways to upgrade: You have upgraded to {app_pro}!\n\nThank you for supporting the {network_name}. @@ -1277,6 +1277,7 @@ Last updated {relative_time} ago Updates Updating... + Upgrade Upgrade {app_name} Upgrade to Uploading From ee13de11838f2da607ede434dee82a4191f442e1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 21 Oct 2025 08:47:51 +1100 Subject: [PATCH 052/219] Sorting out the Non-Pro screens for the Pro Settings screen --- .../chooseplan/ChoosePlanScreen.kt | 35 ++++++++++++++----- .../PlayStoreSubscriptionManager.kt | 2 +- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 47c24508df..e128a5abc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -125,8 +126,10 @@ fun ChoosePlan( .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) .format() - //todo PRO cater for brand new subscription in here - else -> "" + is SubscriptionType.NeverSubscribed -> + Phrase.from(context.getText(R.string.proChooseAccess)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() } Text( @@ -171,6 +174,7 @@ fun ChoosePlan( is SubscriptionType.Active.Expiring -> Phrase.from(LocalContext.current, R.string.updateAccess) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() + is SubscriptionType.NeverSubscribed -> stringResource(R.string.upgrade) else -> Phrase.from(LocalContext.current, R.string.updateAccess) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() @@ -188,6 +192,25 @@ fun ChoosePlan( Spacer(Modifier.height(LocalDimensions.current.xxsSpacing)) + val footer = when (planData.subscriptionType) { + is SubscriptionType.Expired -> + Phrase.from(LocalContext.current.getText(R.string.proRenewTosPrivacy)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(ICON_KEY, iconExternalLink) + .format() + + is SubscriptionType.Active -> Phrase.from(LocalContext.current.getText(R.string.proTosPrivacy)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(ICON_KEY, iconExternalLink) + .format() + + is SubscriptionType.NeverSubscribed -> + Phrase.from(LocalContext.current.getText(R.string.proUpgradingTosPrivacy)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(ICON_KEY, iconExternalLink) + .format() + } + Text( modifier = Modifier.fillMaxWidth() .clickable( @@ -200,13 +223,7 @@ fun ChoosePlan( vertical = LocalDimensions.current.xxsSpacing ) .clip(MaterialTheme.shapes.extraSmall), - text = annotatedStringResource( - Phrase.from(LocalContext.current.getText(R.string.proTosPrivacy)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(ICON_KEY, iconExternalLink) - .put(ICON_KEY, iconExternalLink) - .format() - ), + text = annotatedStringResource(footer), textAlign = TextAlign.Center, style = LocalType.current.small, color = LocalColors.current.text, diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index e4023db77a..db616eecc1 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -33,7 +33,7 @@ class PlayStoreSubscriptionManager @Inject constructor( override val description = "" override val iconRes = null - override val supportsBilling: Boolean = false + override val supportsBilling: Boolean = true override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From c7293928bc6781f38d22fc81dbc2f39aee7066eb Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 21 Oct 2025 09:00:57 +1100 Subject: [PATCH 053/219] "No Billing" debug switch --- .../libsession/utilities/TextSecurePreferences.kt | 11 +++++++++++ .../org/thoughtcrime/securesms/debugmenu/DebugMenu.kt | 10 ++++++++++ .../securesms/debugmenu/DebugMenuViewModel.kt | 10 ++++++++++ .../pro/subscription/PlayStoreSubscriptionManager.kt | 5 ++++- 4 files changed, 35 insertions(+), 1 deletion(-) 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 4dfe9bcb29..da8d6afb36 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -218,6 +218,8 @@ interface TextSecurePreferences { fun setDebugSubscriptionType(status: DebugMenuViewModel.DebugSubscriptionStatus?) fun getDebugProPlanStatus(): DebugMenuViewModel.DebugProPlanStatus? fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) + fun getDebugForceNoBilling(): Boolean + fun setDebugForceNoBilling(hasBilling: Boolean) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? @@ -380,6 +382,7 @@ interface TextSecurePreferences { const val DEBUG_MESSAGE_FEATURES = "debug_message_features" const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" const val DEBUG_PRO_PLAN_STATUS = "debug_pro_plan_status" + const val DEBUG_FORCE_NO_BILLING = "debug_pro_has_billing" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" @@ -1780,6 +1783,14 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_PRO_PLAN_STATUS) } + override fun getDebugForceNoBilling(): Boolean { + return getBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, false) + } + + override fun setDebugForceNoBilling(hasBilling: Boolean) { + setBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, hasBilling) + } + override fun getSubscriptionProvider(): String? { return getStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, null) } 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 c874e1df2d..ad8a6100e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -300,6 +300,15 @@ fun DebugMenu( } } + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Force \"No Billing\" APIs", + checked = uiState.forceNoBilling, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ForceNoBilling(it)) + } + ) + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) DebugSwitchRow( text = "Set all incoming messages as Pro", @@ -773,6 +782,7 @@ fun PreviewDebugMenu() { debugProPlanStatus = setOf(DebugMenuViewModel.DebugProPlanStatus.NORMAL), selectedDebugProPlanStatus = DebugMenuViewModel.DebugProPlanStatus.NORMAL, debugProPlans = emptyList(), + forceNoBilling = false ), sendCommand = {}, onClose = {} 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 5dc37e077a..5fc01a9cdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -102,6 +102,7 @@ class DebugMenuViewModel @Inject constructor( debugProPlans = subscriptionManagers.asSequence() .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), + forceNoBilling = textSecurePreferences.getDebugForceNoBilling(), ) ) val uiState: StateFlow @@ -268,6 +269,13 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.ForceNoBilling -> { + textSecurePreferences.setDebugForceNoBilling(command.set) + _uiState.update { + it.copy(forceNoBilling = command.set) + } + } + is Commands.ForcePostPro -> { textSecurePreferences.setForcePostPro(command.set) _uiState.update { @@ -426,6 +434,7 @@ class DebugMenuViewModel @Inject constructor( val debugProPlanStatus: Set, val selectedDebugProPlanStatus: DebugProPlanStatus, val debugProPlans: List, + val forceNoBilling: Boolean, ) enum class DatabaseInspectorState { @@ -461,6 +470,7 @@ class DebugMenuViewModel @Inject constructor( data class ForceCurrentUserAsPro(val set: Boolean) : Commands() data class ForceOtherUsersAsPro(val set: Boolean) : Commands() data class ForceIncomingMessagesAsPro(val set: Boolean) : Commands() + data class ForceNoBilling(val set: Boolean) : Commands() data class ForcePostPro(val set: Boolean) : Commands() data class ForceShortTTl(val set: Boolean) : Commands() data class SetMessageProFeature(val feature: ProStatusManager.MessageProFeature, val set: Boolean) : Commands() diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index db616eecc1..54e7bd96c1 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.util.CurrentActivityObserver @@ -27,13 +28,15 @@ class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, + private val prefs: TextSecurePreferences ) : SubscriptionManager { override val id = "google_play_store" override val name = "Google Play Store" override val description = "" override val iconRes = null - override val supportsBilling: Boolean = true + override val supportsBilling: Boolean + get() = !prefs.getDebugForceNoBilling() override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" From f0a3bb43d296f9b49fd87ea1322f744ccf0c5a08 Mon Sep 17 00:00:00 2001 From: Aerilym <5667907+Aerilym@users.noreply.github.com> Date: Tue, 21 Oct 2025 03:19:34 +0000 Subject: [PATCH 054/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+cs+CZ/strings.xml | 3 --- app/src/main/res/values/strings.xml | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 7f28211f45..8e97b3c80e 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -940,7 +940,6 @@ Animované zobrazované obrázky Nastavte si animované obrázky GIF a WebP jako svůj zobrazovaný profilový obrázek. Nahrajte GIFy se - {pro} se automaticky obnoví za {time} Odznak {pro} Zobrazit odznak {app_pro} ostatním uživatelům Odznaky @@ -1005,7 +1004,6 @@ Mrzí nás, že to rušíte. Než požádáte o vrácení peněz, přečtěte si informace, které byste měli vědět. Váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, v případě že je vyřizováno mnoho žádostí. Vracení peněz za {pro} - Obnovit {pro} Beta Žádost o vrácení peněz Posílejte více se Nastavení {pro} @@ -1093,7 +1091,6 @@ Odstranit Odebrání hesla selhalo Odstraňte své aktuální heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí náhodně vygenerovaného klíče uloženého ve vašem zařízení. - Obnovit Odpovědět Požádat o vrácení platby Odeslat znovu diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27c0b3dbc5..7cc6c082d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1046,6 +1046,7 @@ Want to pin more than 5 conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to more pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} + Pro renewal unsuccessful, retrying soon Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} Refund Requested Send more with From bee1a1b970ececff38949f23466fd50900b9c539 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 21 Oct 2025 15:48:48 +1100 Subject: [PATCH 055/219] Fixing string keys --- .../java/org/session/libsession/utilities/StringSubKeys.kt | 1 - .../prosettings/chooseplan/ChoosePlanNonOriginating.kt | 5 ++--- .../preferences/prosettings/chooseplan/ChoosePlanScreen.kt | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt index 7d6a2bbb45..542203da87 100644 --- a/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt +++ b/app/src/main/java/org/session/libsession/utilities/StringSubKeys.kt @@ -50,7 +50,6 @@ object StringSubstitutionConstants { const val BUILD_VARIANT_KEY: StringSubKey = "build_variant" const val APP_PRO_KEY: StringSubKey = "app_pro" const val PRO_KEY: StringSubKey = "pro" - const val CURRENT_PLAN_KEY: StringSubKey = "current_plan" const val CURRENT_PLAN_LENGTH_KEY: StringSubKey = "current_plan_length" const val SELECTED_PLAN_LENGTH_KEY: StringSubKey = "selected_plan_length" const val SELECTED_PLAN_LENGTH_SINGULAR_KEY: StringSubKey = "selected_plan_length_singular" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index c8fcf64616..8cb7861a4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase @@ -13,7 +12,7 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DEVICE_TYPE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PLATFORM_ACCOUNT_KEY @@ -55,7 +54,7 @@ fun ChoosePlanNonOriginating( is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatedAutoShort)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( + .put(CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( context = context, amount = subscription.duration.duration.months, unit = MeasureUnit.MONTH diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index e128a5abc8..7681c84c08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -49,7 +49,7 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.CURRENT_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.MONTHLY_PRICE_KEY @@ -117,7 +117,7 @@ fun ChoosePlan( is SubscriptionType.Active.AutoRenewing -> Phrase.from(context.getText(R.string.proAccessActivatesAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put( - CURRENT_PLAN_KEY, DateUtils.getLocalisedTimeDuration( + CURRENT_PLAN_LENGTH_KEY, DateUtils.getLocalisedTimeDuration( context = context, amount = planData.subscriptionType.duration.duration.months, unit = MeasureUnit.MONTH From 1a89b6ceb48b1e46be489e8212b96f91e373358b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 05:06:53 +0000 Subject: [PATCH 056/219] Bump uiTestJunit4Version from 1.9.2 to 1.9.3 (#1618) --- 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 d96327d4cc..1cd175d618 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ subsamplingScaleImageViewVersion = "3.10.0" testCoreVersion = "1.7.0" truthVersion = "1.4.5" turbineVersion = "1.2.1" -uiTestJunit4Version = "1.9.2" +uiTestJunit4Version = "1.9.3" workRuntimeKtxVersion = "2.10.5" zxingVersion = "3.5.3" huaweiPushVersion = "6.13.0.300" From 1bd5715cad9678210c91a8e0f6f247f0e276cc0f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 11:27:10 +1100 Subject: [PATCH 057/219] Fixed up DisappearingMessages screen The standalone activity is not required. We can now navigate to the conversation settings screen with a chosen destination Implemented the new Navigator usage across the app too --- app/src/main/AndroidManifest.xml | 3 - .../DisappearingMessagesActivity.kt | 40 ---- .../DisappearingMessagesViewModel.kt | 5 +- .../conversation/v2/ConversationActivityV2.kt | 10 +- .../conversation/v2/ConversationViewModel.kt | 4 +- .../settings/ConversationSettingsActivity.kt | 23 +- .../settings/ConversationSettingsNavHost.kt | 51 ++-- .../settings/ConversationSettingsViewModel.kt | 7 +- .../securesms/home/HomeActivity.kt | 2 - .../securesms/home/HomeDialogs.kt | 2 - .../StartConversationSheet.kt | 7 +- .../prosettings/ProSettingsActivity.kt | 14 +- .../prosettings/ProSettingsNavHost.kt | 136 ++++++----- .../prosettings/ProSettingsViewModel.kt | 17 +- .../securesms/ui/ProComponents.kt | 217 ++++++++++-------- .../thoughtcrime/securesms/ui/UINavigator.kt | 4 +- 16 files changed, 302 insertions(+), 240 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ddf065ec3..cce3a226fb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -190,9 +190,6 @@ android:screenOrientation="portrait" /> - diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt deleted file mode 100644 index b8784a3d44..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesActivity.kt +++ /dev/null @@ -1,40 +0,0 @@ -package org.thoughtcrime.securesms.conversation.disappearingmessages - -import androidx.compose.runtime.Composable -import androidx.core.content.IntentCompat -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import dagger.hilt.android.AndroidEntryPoint -import network.loki.messenger.BuildConfig -import org.session.libsession.messaging.messages.ExpirationConfiguration -import org.session.libsession.utilities.Address -import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.DisappearingMessagesScreen - -@AndroidEntryPoint -class DisappearingMessagesActivity: FullComposeScreenLockActivity() { - - @Composable - override fun ComposeContent() { - val viewModel: DisappearingMessagesViewModel = - hiltViewModel { factory -> - factory.create( - address = requireNotNull( - IntentCompat.getParcelableExtra(intent, ARG_ADDRESS, Address::class.java) - ) { - "DisappearingMessagesActivity requires an Address to be passed in via the intent." - }, - isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, - showDebugOptions = BuildConfig.BUILD_TYPE != "release" - ) - } - - DisappearingMessagesScreen( - viewModel = viewModel, - onBack = { finish() }, - ) - } - - companion object { - const val ARG_ADDRESS = "address" - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt index 1ba68f20a6..cb9a40ca23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessagesViewModel.kt @@ -31,9 +31,9 @@ class DisappearingMessagesViewModel @AssistedInject constructor( @Assisted private val address: Address, @Assisted("isNewConfigEnabled") private val isNewConfigEnabled: Boolean, @Assisted("showDebugOptions") private val showDebugOptions: Boolean, + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val disappearingMessages: DisappearingMessages, - private val navigator: UINavigator, private val recipientRepository: RecipientRepository, ) : ViewModel() { @@ -96,7 +96,8 @@ class DisappearingMessagesViewModel @AssistedInject constructor( fun create( address: Address, @Assisted("isNewConfigEnabled") isNewConfigEnabled: Boolean, - @Assisted("showDebugOptions") showDebugOptions: Boolean + @Assisted("showDebugOptions") showDebugOptions: Boolean, + navigator: UINavigator ): DisappearingMessagesViewModel } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 68139a8268..f69b716d6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -124,7 +124,6 @@ import org.thoughtcrime.securesms.audio.AudioRecorderHandle import org.thoughtcrime.securesms.audio.recordAudio import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel -import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog @@ -150,6 +149,7 @@ import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDel import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar import org.thoughtcrime.securesms.conversation.v2.search.SearchViewModel import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsActivity +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsDestination import org.thoughtcrime.securesms.conversation.v2.settings.notification.NotificationSettingsActivity import org.thoughtcrime.securesms.conversation.v2.utilities.AttachmentManager import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities @@ -692,9 +692,11 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } is ConversationUiEvent.ShowDisappearingMessages -> { - val intent = Intent(this@ConversationActivityV2, DisappearingMessagesActivity::class.java).apply { - putExtra(DisappearingMessagesActivity.ARG_ADDRESS, event.address) - } + val intent = ConversationSettingsActivity.createIntent( + context = this@ConversationActivityV2, + address = event.address, + startDestination = ConversationSettingsDestination.RouteDisappearingMessages + ) startActivity(intent) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 89c980c0c5..21113c8bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -1293,7 +1293,7 @@ class ConversationViewModel @AssistedInject constructor( } } - _uiEvents.tryEmit(ConversationUiEvent.ShowDisappearingMessages(convo.address)) + _uiEvents.tryEmit(ConversationUiEvent.ShowDisappearingMessages(address)) } } @@ -1455,7 +1455,7 @@ data class UiMessage(val id: Long, val message: String) sealed interface ConversationUiEvent { data class NavigateToConversation(val address: Address.Conversable) : ConversationUiEvent - data class ShowDisappearingMessages(val address: Address) : ConversationUiEvent + data class ShowDisappearingMessages(val address: Address.Conversable) : ConversationUiEvent data class ShowNotificationSettings(val address: Address) : ConversationUiEvent data class ShowGroupMembers(val groupAddress: Address.Group) : ConversationUiEvent data class ShowConversationSettings(val threadAddress: Address.Conversable) : ConversationUiEvent diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt index 9e6058c79a..fedeadd188 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsActivity.kt @@ -7,32 +7,39 @@ import androidx.core.content.IntentCompat import dagger.hilt.android.AndroidEntryPoint import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.ui.UINavigator -import javax.inject.Inject @AndroidEntryPoint class ConversationSettingsActivity: FullComposeScreenLockActivity() { companion object { - const val THREAD_ADDRESS = "conversation_settings_thread_address" + private const val THREAD_ADDRESS = "conversation_settings_thread_address" + private const val EXTRA_START_DESTINATION = "start_destination" - fun createIntent(context: Context, address: Address.Conversable): Intent { + fun createIntent( + context: Context, + address: Address.Conversable, + startDestination: ConversationSettingsDestination = ConversationSettingsDestination.RouteConversationSettings + ): Intent { return Intent(context, ConversationSettingsActivity::class.java).apply { putExtra(THREAD_ADDRESS, address) + putExtra(EXTRA_START_DESTINATION, startDestination) } } } - @Inject - lateinit var navigator: UINavigator - @Composable override fun ComposeContent() { + val startDestination = IntentCompat.getParcelableExtra( + intent, + EXTRA_START_DESTINATION, + ConversationSettingsDestination::class.java + ) ?: ConversationSettingsDestination.RouteConversationSettings + ConversationSettingsNavHost( address = requireNotNull(IntentCompat.getParcelableExtra(intent, THREAD_ADDRESS, Address.Conversable::class.java)) { "ConversationSettingsActivity requires an Address to be passed in the intent." }, - navigator = navigator, + startDestination = startDestination, returnResult = { code, value -> setResult(RESULT_OK, Intent().putExtra(code, value)) finish() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 02c288e96b..4325577ef0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -1,11 +1,13 @@ package org.thoughtcrime.securesms.conversation.v2.settings import android.annotation.SuppressLint +import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -14,6 +16,8 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import network.loki.messenger.BuildConfig import org.session.libsession.messaging.messages.ExpirationConfiguration @@ -39,11 +43,13 @@ import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.horizontalSlideComposable // Destinations -sealed interface ConversationSettingsDestination { +sealed interface ConversationSettingsDestination: Parcelable { @Serializable + @Parcelize data object RouteConversationSettings: ConversationSettingsDestination @Serializable + @Parcelize data class RouteGroupMembers private constructor( private val address: String ): ConversationSettingsDestination { @@ -53,6 +59,7 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data class RouteManageMembers private constructor( private val address: String ): ConversationSettingsDestination { @@ -62,6 +69,7 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data class RouteInviteToGroup private constructor( private val address: String, val excludingAccountIDs: List @@ -73,15 +81,19 @@ sealed interface ConversationSettingsDestination { } @Serializable + @Parcelize data object RouteDisappearingMessages: ConversationSettingsDestination @Serializable + @Parcelize data object RouteAllMedia: ConversationSettingsDestination @Serializable + @Parcelize data object RouteNotifications: ConversationSettingsDestination @Serializable + @Parcelize data class RouteInviteToCommunity( val communityUrl: String ): ConversationSettingsDestination @@ -92,12 +104,22 @@ sealed interface ConversationSettingsDestination { @Composable fun ConversationSettingsNavHost( address: Address.Conversable, - navigator: UINavigator, + startDestination: ConversationSettingsDestination = RouteConversationSettings, returnResult: (String, Boolean) -> Unit, onBack: () -> Unit ){ SharedTransitionLayout { val navController = rememberNavController() + val scope = rememberCoroutineScope() + val navigator: UINavigator = remember { UINavigator() } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + scope.launch { navigator.navigateUp() } + } else { + onBack() // Finish activity if at root + } + } ObserveAsEvents(flow = navigator.navigationActions) { action -> when (action) { @@ -107,7 +129,7 @@ fun ConversationSettingsNavHost( action.navOptions(this) } - NavigationAction.NavigateUp -> navController.navigateUp() + NavigationAction.NavigateUp -> handleBack() is NavigationAction.NavigateToIntent -> { navController.context.startActivity(action.intent) @@ -119,12 +141,12 @@ fun ConversationSettingsNavHost( } } - NavHost(navController = navController, startDestination = RouteConversationSettings) { + NavHost(navController = navController, startDestination = startDestination) { // Conversation Settings horizontalSlideComposable { val viewModel = hiltViewModel { factory -> - factory.create(address) + factory.create(address, navigator) } val lifecycleOwner = LocalLifecycleOwner.current @@ -154,7 +176,7 @@ fun ConversationSettingsNavHost( GroupMembersScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -178,7 +200,7 @@ fun ConversationSettingsNavHost( ) }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -208,10 +230,10 @@ fun ConversationSettingsNavHost( //send invites from the manage group screen editGroupViewModel.onContactSelected(viewModel.currentSelected) - navController.popBackStack() + handleBack() }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, banner = { GroupMinimumVersionBanner() @@ -244,7 +266,7 @@ fun ConversationSettingsNavHost( viewModel.clearSelection() }, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -256,14 +278,15 @@ fun ConversationSettingsNavHost( factory.create( address = address, isNewConfigEnabled = ExpirationConfiguration.isNewConfigEnabled, - showDebugOptions = BuildConfig.BUILD_TYPE != "release" + showDebugOptions = BuildConfig.BUILD_TYPE != "release", + navigator = navigator ) } DisappearingMessagesScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -278,7 +301,7 @@ fun ConversationSettingsNavHost( MediaOverviewScreen( viewModel = viewModel, onClose = dropUnlessResumed { - navController.popBackStack() + handleBack() }, ) } @@ -293,7 +316,7 @@ fun ConversationSettingsNavHost( NotificationSettingsScreen( viewModel = viewModel, onBack = dropUnlessResumed { - navController.popBackStack() + handleBack() } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index de86d4cec5..0d98c5c126 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -72,13 +72,13 @@ import org.thoughtcrime.securesms.util.AvatarUtils @HiltViewModel(assistedFactory = ConversationSettingsViewModel.Factory::class) class ConversationSettingsViewModel @AssistedInject constructor( @Assisted private val address: Address.Conversable, + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val avatarUtils: AvatarUtils, private val repository: ConversationRepository, private val configFactory: ConfigFactoryProtocol, private val storage: StorageProtocol, private val conversationRepository: ConversationRepository, - private val navigator: UINavigator, private val groupManagerV2: GroupManagerV2, private val groupManager: GroupManagerV2, private val openGroupManager: OpenGroupManager, @@ -1385,7 +1385,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(address: Address.Conversable): ConversationSettingsViewModel + fun create( + address: Address.Conversable, + navigator: UINavigator + ): ConversationSettingsViewModel } data class UIState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 3318d50bf1..33f91e5a8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -135,7 +135,6 @@ class HomeActivity : ScreenLockActionBarActivity(), @Inject lateinit var proStatusManager: ProStatusManager @Inject lateinit var recipientRepository: RecipientRepository @Inject lateinit var avatarUtils: AvatarUtils - @Inject lateinit var startConversationNavigator: UINavigator private val globalSearchViewModel by viewModels() private val homeViewModel by viewModels() @@ -294,7 +293,6 @@ class HomeActivity : ScreenLockActionBarActivity(), val dialogsState by homeViewModel.dialogsState.collectAsState() HomeDialogs( dialogsState = dialogsState, - startConversationNavigator = startConversationNavigator, sendCommand = homeViewModel::onCommand ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 45ab8a9a8d..b221a45971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -32,7 +32,6 @@ import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable fun HomeDialogs( dialogsState: HomeViewModel.DialogsState, - startConversationNavigator: UINavigator, sendCommand: (HomeViewModel.Commands) -> Unit ) { SessionMaterialTheme { @@ -61,7 +60,6 @@ fun HomeDialogs( if(dialogsState.showStartConversationSheet != null){ StartConversationSheet( accountId = dialogsState.showStartConversationSheet.accountId, - navigator = startConversationNavigator, onDismissRequest = { sendCommand(HomeViewModel.Commands.HideStartConversationSheet) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index 6a9c7cc4f0..fc3652f386 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,7 +49,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme fun StartConversationSheet( modifier: Modifier = Modifier, accountId: String, - navigator: UINavigator, onDismissRequest: () -> Unit, ){ val sheetState = rememberModalBottomSheetState( @@ -71,7 +71,6 @@ fun StartConversationSheet( ) { StartConversationNavHost( accountId = accountId, - navigator = navigator, onClose = { scope.launch { sheetState.hide() @@ -107,11 +106,12 @@ sealed interface StartConversationDestination { @Composable fun StartConversationNavHost( accountId: String, - navigator: UINavigator, onClose: () -> Unit ){ SharedTransitionLayout { val navController = rememberNavController() + val navigator: UINavigator = + remember { UINavigator() } ObserveAsEvents(flow = navigator.navigationActions) { action -> when (action) { @@ -235,7 +235,6 @@ fun PreviewStartConversationSheet(){ StartConversationSheet( accountId = "", onDismissRequest = {}, - navigator = UINavigator() ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index da7e5cbd20..ba109a9f8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -3,17 +3,13 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import androidx.compose.runtime.Composable +import androidx.core.content.IntentCompat import dagger.hilt.android.AndroidEntryPoint import org.thoughtcrime.securesms.FullComposeScreenLockActivity -import org.thoughtcrime.securesms.ui.UINavigator -import javax.inject.Inject @AndroidEntryPoint class ProSettingsActivity: FullComposeScreenLockActivity() { - @Inject - lateinit var navigator: UINavigator - companion object { private const val EXTRA_START_DESTINATION = "start_destination" @@ -29,12 +25,14 @@ class ProSettingsActivity: FullComposeScreenLockActivity() { @Composable override fun ComposeContent() { - val startDestination = intent.getParcelableExtra( - EXTRA_START_DESTINATION + val startDestination = IntentCompat.getParcelableExtra( + intent, + EXTRA_START_DESTINATION, + ProSettingsDestination::class.java ) ?: ProSettingsDestination.Home ProSettingsNavHost( - navigator = navigator, + showClose = false, startDestination = startDestination, onBack = this::finish ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index efb35fe9f2..ab467a5dd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -5,11 +5,18 @@ import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.navigation.NavController import androidx.navigation.compose.NavHost +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -53,22 +60,22 @@ sealed interface ProSettingsDestination: Parcelable { data object RefundSubscription: ProSettingsDestination } +@Serializable object ProSettingsGraph + @SuppressLint("RestrictedApi") @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun ProSettingsNavHost( - navigator: UINavigator, startDestination: ProSettingsDestination = Home, + showClose: Boolean, onBack: () -> Unit ){ SharedTransitionLayout { val navController = rememberNavController() val scope = rememberCoroutineScope() - - // all screens within the Pro Flow can share the same VM - val viewModel = hiltViewModel() - - val dialogsState by viewModel.dialogState.collectAsState() + val navigator: UINavigator = remember { + UINavigator() + } val handleBack: () -> Unit = { if (navController.previousBackStackEntry != null) { @@ -87,7 +94,7 @@ fun ProSettingsNavHost( action.navOptions(this) } - NavigationAction.NavigateUp -> navController.navigateUp() + NavigationAction.NavigateUp -> handleBack() is NavigationAction.NavigateToIntent -> { navController.context.startActivity(action.intent) @@ -97,58 +104,83 @@ fun ProSettingsNavHost( } } - NavHost(navController = navController, startDestination = startDestination) { - // Home - horizontalSlideComposable { - ProSettingsHomeScreen( - viewModel = viewModel, - onBack = onBack, - ) - } + NavHost(navController = navController, startDestination = ProSettingsGraph) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + ProSettingsHomeScreen( + viewModel = viewModel, + onBack = onBack, + ) + } - // Subscription plan selection - horizontalSlideComposable { - UpdatePlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - horizontalSlideComposable { - GetOrRenewPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } + // Subscription plan selection + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + UpdatePlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + GetOrRenewPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } - // Subscription plan confirmation - horizontalSlideComposable { - PlanConfirmationScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } + // Subscription plan confirmation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + PlanConfirmationScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } - // Refund - horizontalSlideComposable { - RefundPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } + // Refund + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + RefundPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } - // Cancellation - horizontalSlideComposable { - CancelPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) + // Cancellation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + CancelPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } } } // Dialogs - ProSettingsDialogs( - dialogsState = dialogsState, - sendCommand = viewModel::onCommand, - ) + // the composable need to wait until the graph has been rendered + val graphReady = remember(navController.currentBackStackEntryAsState().value) { + runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() + } + graphReady?.let { entry -> + val vm = navController.proGraphViewModel(entry, navigator) + val dialogsState by vm.dialogState.collectAsState() + ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) + } } +} + +@Composable +fun NavController.proGraphViewModel( + entry: androidx.navigation.NavBackStackEntry, + navigator: UINavigator +): ProSettingsViewModel { + val graphEntry = remember(entry) { getBackStackEntry(ProSettingsGraph) } + return hiltViewModel< + ProSettingsViewModel, + ProSettingsViewModel.Factory + >(graphEntry) { factory -> factory.create(navigator) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 3464985b08..39f4fd840e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -8,6 +8,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavOptionsBuilder import com.squareup.phrase.Phrase +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -31,6 +34,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -48,15 +52,20 @@ import javax.inject.Inject @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -@HiltViewModel -class ProSettingsViewModel @Inject constructor( - @ApplicationContext private val context: Context, - private val navigator: UINavigator, +@HiltViewModel(assistedFactory = ProSettingsViewModel.Factory::class) +class ProSettingsViewModel @AssistedInject constructor( + @Assisted private val navigator: UINavigator, + @param:ApplicationContext private val context: Context, private val proStatusManager: ProStatusManager, private val subscriptionCoordinator: SubscriptionCoordinator, private val dateUtils: DateUtils ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(navigator: UINavigator): ProSettingsViewModel + } + private val _proSettingsUIState: MutableStateFlow = MutableStateFlow(ProSettingsState()) val proSettingsUIState: StateFlow = _proSettingsUIState diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index c19697a5d9..ee84b7b14e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -34,10 +34,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -67,12 +69,16 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideSubcomposition import com.bumptech.glide.integration.compose.RequestState import com.squareup.phrase.Phrase +import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar +import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.components.FillButtonRect import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.TertiaryFillButtonRect @@ -205,6 +211,10 @@ private fun PreviewProBadgeText( } } +/** + * This composable comprises of the CTA itself + * and the bottom sheet with the whole pro settings content + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionProCTA( @@ -217,95 +227,138 @@ fun SessionProCTA( features: List = emptyList(), positiveButtonText: String? = stringResource(R.string.theContinue), negativeButtonText: String? = stringResource(R.string.cancel), - onUpgrade: () -> Unit = {}, + onUpgrade: (() -> Unit)? = null, onCancel: () -> Unit = {}, ){ - BasicAlertDialog( - modifier = modifier, - onDismissRequest = onCancel, - content = { - DialogBg { - Column(modifier = Modifier.fillMaxWidth()) { - // hero image - BottomFadingEdgeBox( - fadingEdgeHeight = 70.dp, - fadingColor = LocalColors.current.backgroundSecondary, - content = { _ -> - content() - }, - ) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + val scope = rememberCoroutineScope() - // content - Column( - modifier = Modifier - .fillMaxWidth() - .padding(LocalDimensions.current.smallSpacing) - ) { - // title - ProBadgeText( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = title, - badgeAtStart = badgeAtStart, - badgeColors = if(disabled) proBadgeColorDisabled() else proBadgeColorStandard(), - ) + // We should avoid internal state in a composable but having the bottom sheet + // here avoids re-defining the sheet in multiple places in the app + var showDialog by remember { mutableStateOf(true) } + var showProSheet by remember { mutableStateOf(false) } - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + // default handling of the upgrade button + val defaultUpgrade: () -> Unit = { + showProSheet = true + showDialog = false + } - // main message - textContent() + if(showDialog) { + BasicAlertDialog( + modifier = modifier, + onDismissRequest = onCancel, + content = { + DialogBg { + Column(modifier = Modifier.fillMaxWidth()) { + // hero image + BottomFadingEdgeBox( + fadingEdgeHeight = 70.dp, + fadingColor = LocalColors.current.backgroundSecondary, + content = { _ -> + content() + }, + ) - Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + // content + Column( + modifier = Modifier + .fillMaxWidth() + .padding(LocalDimensions.current.smallSpacing) + ) { + // title + ProBadgeText( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = title, + badgeAtStart = badgeAtStart, + badgeColors = if (disabled) proBadgeColorDisabled() else proBadgeColorStandard(), + ) - // features - if(features.isNotEmpty()) { - features.forEachIndexed { index, feature -> - ProCTAFeature(data = feature) - if (index < features.size - 1) { - Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - } - } + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) + + // main message + textContent() Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) - } - // buttons - Row( - Modifier.height(IntrinsicSize.Min) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - LocalDimensions.current.xsSpacing, - Alignment.CenterHorizontally - ), - ) { - positiveButtonText?.let { - AccentFillButtonRect( - modifier = Modifier.then( - if(negativeButtonText != null) - Modifier.weight(1f) - else Modifier - ).shimmerOverlay(), - text = it, - onClick = onUpgrade - ) + // features + if (features.isNotEmpty()) { + features.forEachIndexed { index, feature -> + ProCTAFeature(data = feature) + if (index < features.size - 1) { + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + } + } + + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) } - negativeButtonText?.let { - TertiaryFillButtonRect( - modifier = Modifier.then( - if(positiveButtonText != null) - Modifier.weight(1f) - else Modifier - ), - text = it, - onClick = onCancel - ) + // buttons + Row( + Modifier.height(IntrinsicSize.Min) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + LocalDimensions.current.xsSpacing, + Alignment.CenterHorizontally + ), + ) { + positiveButtonText?.let { + AccentFillButtonRect( + modifier = Modifier.then( + if (negativeButtonText != null) + Modifier.weight(1f) + else Modifier + ).shimmerOverlay(), + text = it, + onClick = onUpgrade ?: defaultUpgrade + ) + } + + negativeButtonText?.let { + TertiaryFillButtonRect( + modifier = Modifier.then( + if (positiveButtonText != null) + Modifier.weight(1f) + else Modifier + ), + text = it, + onClick = onCancel + ) + } } } } } } + ) + } + + if(showProSheet) { + BaseBottomSheet( + modifier = modifier, + sheetState = sheetState, + dragHandle = null, + onDismissRequest = { + scope.launch { + sheetState.hide() + onCancel() + } + } + ) { + ProSettingsNavHost( + startDestination = ProSettingsDestination.Home, + showClose = true, + onBack = { + scope.launch { + sheetState.hide() + onCancel() + } + } + ) } - ) + } } sealed interface CTAFeature { @@ -332,7 +385,7 @@ fun SimpleSessionProCTA( features: List = emptyList(), positiveButtonText: String? = stringResource(R.string.theContinue), negativeButtonText: String? = stringResource(R.string.cancel), - onUpgrade: () -> Unit = {}, + onUpgrade: (() -> Unit)? = null, onCancel: () -> Unit = {}, ){ SessionProCTA( @@ -385,7 +438,7 @@ fun AnimatedSessionProCTA( features: List = emptyList(), positiveButtonText: String? = stringResource(R.string.theContinue), negativeButtonText: String? = stringResource(R.string.cancel), - onUpgrade: () -> Unit = {}, + onUpgrade: (() -> Unit)? = null, onCancel: () -> Unit = {}, ){ SessionProCTA( @@ -465,7 +518,6 @@ fun CTAAnimatedImages( @Composable fun GenericProCTA( onDismissRequest: () -> Unit, - onPostAction: (() -> Unit)? = null // a function for optional code once an action has been taken ){ val context = LocalContext.current AnimatedSessionProCTA( @@ -481,11 +533,6 @@ fun GenericProCTA( CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - onPostAction?.invoke() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -508,10 +555,6 @@ fun LongMessageProCTA( CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -535,10 +578,6 @@ fun AnimatedProfilePicProCTA( CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } @@ -574,10 +613,6 @@ fun PinProCTA( CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), - onUpgrade = { - onDismissRequest() - //todo PRO go to screen once it exists - }, onCancel = { onDismissRequest() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 599873491d..6fdcf5ce90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -5,10 +5,10 @@ import androidx.navigation.NavOptionsBuilder import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow +import org.session.libsignal.utilities.Log import javax.inject.Inject -@ActivityRetainedScoped -class UINavigator @Inject constructor() { +class UINavigator () { private val _navigationActions = Channel>() val navigationActions = _navigationActions.receiveAsFlow() From 8afec55df1015e5accef732dcd96298fd0bccdd1 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 12:03:28 +1100 Subject: [PATCH 058/219] Fixing the navhost back handling --- .../v2/settings/ConversationSettingsNavHost.kt | 5 +---- .../preferences/prosettings/ProSettingsNavHost.kt | 8 +------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 4325577ef0..5a540d1d0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner @@ -16,7 +15,6 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import network.loki.messenger.BuildConfig @@ -110,12 +108,11 @@ fun ConversationSettingsNavHost( ){ SharedTransitionLayout { val navController = rememberNavController() - val scope = rememberCoroutineScope() val navigator: UINavigator = remember { UINavigator() } val handleBack: () -> Unit = { if (navController.previousBackStackEntry != null) { - scope.launch { navigator.navigateUp() } + navController.navigateUp() } else { onBack() // Finish activity if at root } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index ab467a5dd7..be61675ae1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -5,20 +5,15 @@ import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModelStore -import androidx.lifecycle.ViewModelStoreOwner import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription @@ -72,14 +67,13 @@ fun ProSettingsNavHost( ){ SharedTransitionLayout { val navController = rememberNavController() - val scope = rememberCoroutineScope() val navigator: UINavigator = remember { UINavigator() } val handleBack: () -> Unit = { if (navController.previousBackStackEntry != null) { - scope.launch { navigator.navigateUp() } + navController.navigateUp() } else { onBack() // Finish activity if at root } From f66cf27f8bb53ab8dd4b74a83aff8d99dfd5bf4d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 13:21:01 +1100 Subject: [PATCH 059/219] Proper UI display in sheet mode for pro settings --- .../prosettings/BaseProSettingsScreens.kt | 19 ++++--- .../prosettings/ProSettingsActivity.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 11 ++++ .../prosettings/ProSettingsNavHost.kt | 3 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 2 +- .../securesms/ui/ProComponents.kt | 53 ++++++++++++++----- .../securesms/ui/components/BottomSheets.kt | 4 +- 7 files changed, 67 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 71cf886ad2..d01c4120ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -60,26 +60,29 @@ import org.thoughtcrime.securesms.ui.components.inlineContentMap @Composable fun BaseProSettingsScreen( disabled: Boolean, + hideHomeAppBar: Boolean = false, onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, content: @Composable () -> Unit ){ Scaffold( - topBar = { - BackAppBar( - title = "", - backgroundColor = Color.Transparent, - onBack = onBack, - ) - }, + topBar = if(!hideHomeAppBar){{ + BackAppBar( + title = "", + backgroundColor = Color.Transparent, + onBack = onBack, + ) + }} else {{}}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> Column( modifier = Modifier .fillMaxSize() - .padding(top = paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .padding(top = + (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp)) .consumeWindowInsets(paddings) .padding( horizontal = LocalDimensions.current.spacing, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index ba109a9f8c..710d638c2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -32,7 +32,7 @@ class ProSettingsActivity: FullComposeScreenLockActivity() { ) ?: ProSettingsDestination.Home ProSettingsNavHost( - showClose = false, + hideHomeAppBar = false, startDestination = startDestination, onBack = this::finish ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 6cd615cbf8..c7600d4bfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -99,12 +99,14 @@ import java.time.Instant @Composable fun ProSettingsHomeScreen( viewModel: ProSettingsViewModel, + hideHomeAppBar: Boolean, onBack: () -> Unit, ) { val data by viewModel.proSettingsUIState.collectAsState() ProSettingsHome( data = data, + hideHomeAppBar = hideHomeAppBar, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -114,6 +116,7 @@ fun ProSettingsHomeScreen( @Composable fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, + hideHomeAppBar: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -122,6 +125,7 @@ fun ProSettingsHome( BaseProSettingsScreen( disabled = subscriptionType is SubscriptionType.Expired, + hideHomeAppBar = hideHomeAppBar, onBack = onBack, onHeaderClick = { // add a click handling if the subscription state is loading or errored @@ -917,6 +921,7 @@ fun PreviewProSettingsPro( refreshState = State.Success(Unit), ), ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -950,6 +955,7 @@ fun PreviewProSettingsProLoading( refreshState = State.Loading, ), ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -983,6 +989,7 @@ fun PreviewProSettingsProError( refreshState = State.Error(Exception()), ), ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -1011,6 +1018,7 @@ fun PreviewProSettingsExpired( refreshState = State.Success(Unit), ) ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -1039,6 +1047,7 @@ fun PreviewProSettingsExpiredLoading( refreshState = State.Loading, ) ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -1067,6 +1076,7 @@ fun PreviewProSettingsExpiredError( refreshState = State.Error(Exception()), ) ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) @@ -1086,6 +1096,7 @@ fun PreviewProSettingsNonPro( refreshState = State.Success(Unit), ) ), + hideHomeAppBar = false, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index be61675ae1..3123ee4292 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -62,7 +62,7 @@ sealed interface ProSettingsDestination: Parcelable { @Composable fun ProSettingsNavHost( startDestination: ProSettingsDestination = Home, - showClose: Boolean, + hideHomeAppBar: Boolean, onBack: () -> Unit ){ SharedTransitionLayout { @@ -105,6 +105,7 @@ fun ProSettingsNavHost( val viewModel = navController.proGraphViewModel(entry, navigator) ProSettingsHomeScreen( viewModel = viewModel, + hideHomeAppBar = hideHomeAppBar, onBack = onBack, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index e0290112aa..ea1077065e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -151,7 +151,7 @@ fun AlertDialogContent( Icon( painter = painterResource(id = R.drawable.ic_x), tint = LocalColors.current.text, - contentDescription = "back" + contentDescription = stringResource(R.string.close) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index ee84b7b14e..89f5ba76bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -15,24 +15,30 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -336,27 +342,46 @@ fun SessionProCTA( } if(showProSheet) { + val dismissSheet: () -> Unit = { + scope.launch { + sheetState.hide() + onCancel() + } + } + BaseBottomSheet( modifier = modifier, sheetState = sheetState, dragHandle = null, - onDismissRequest = { - scope.launch { - sheetState.hide() - onCancel() - } - } + onDismissRequest = dismissSheet ) { - ProSettingsNavHost( - startDestination = ProSettingsDestination.Home, - showClose = true, - onBack = { - scope.launch { - sheetState.hide() - onCancel() + BoxWithConstraints(modifier = modifier) { + val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + val targetHeight = + (this.maxHeight - topInset) * 0.94f // sheet should take up 94% of the height, without the status bar + Box( + modifier = Modifier.height(targetHeight), + contentAlignment = Alignment.TopCenter + ) { + ProSettingsNavHost( + startDestination = ProSettingsDestination.Home, + hideHomeAppBar = true, + onBack = dismissSheet + ) + + IconButton( + onClick = dismissSheet, + modifier = Modifier.align(Alignment.TopEnd) + .padding(10.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_x), + tint = LocalColors.current.text, + contentDescription = stringResource(R.string.close) + ) } } - ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt index 1cfb7701cb..8100afa6f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/BottomSheets.kt @@ -62,8 +62,8 @@ fun BaseBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, shape = RoundedCornerShape( - topStart = LocalDimensions.current.xsSpacing, - topEnd = LocalDimensions.current.xsSpacing + topStart = LocalDimensions.current.contentSpacing, + topEnd = LocalDimensions.current.contentSpacing ), dragHandle = dragHandle, containerColor = LocalColors.current.backgroundSecondary From 284f0d84e662602e88f567f42991443d95371f26 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 14:19:30 +1100 Subject: [PATCH 060/219] QA feedback from Pro Settings UI --- .../securesms/preferences/SettingsScreen.kt | 2 +- .../prosettings/CancelPlanNonOriginating.kt | 2 +- .../prosettings/PlanConfirmationScreen.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 28 ++++++++-------- .../prosettings/ProSettingsViewModel.kt | 1 + .../prosettings/RefundPlanNonOriginating.kt | 2 +- .../chooseplan/ChoosePlanNoBilling.kt | 2 +- .../chooseplan/ChoosePlanNonOriginating.kt | 2 +- .../securesms/pro/ProStatusManager.kt | 6 ++-- .../securesms/ui/ProComponents.kt | 32 +++++++++++-------- app/src/main/res/drawable/ic_circle_help.xml | 2 +- .../main/res/drawable/ic_question_custom.xml | 2 +- 12 files changed, 46 insertions(+), 37 deletions(-) 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 62cbb09cbc..46abc5eb9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -1079,7 +1079,7 @@ private fun SettingsScreenPreview() { ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt index 792c8957bb..e8f190426f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanNonOriginating.kt @@ -89,7 +89,7 @@ private fun PreviewUpdatePlan( val context = LocalContext.current CancelPlanNonOriginating ( subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index e2246b8281..5e8c6c0b13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -160,7 +160,7 @@ private fun PreviewPlanConfirmation( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index c7600d4bfb..593ced4eec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -635,7 +635,8 @@ fun ProFeatures( // Longer messages ProFeatureItem( title = stringResource(R.string.proLongerMessages), - subtitle = annotatedStringResource(R.string.proLongerMessagesDescription), + subtitle = if(data is SubscriptionType.Active) annotatedStringResource(R.string.proLongerMessagesDescription) + else annotatedStringResource(R.string.nonProLongerMessagesDescription), icon = R.drawable.ic_message_square, iconGradientStart = primaryBlue, iconGradientEnd = primaryPurple, @@ -820,21 +821,20 @@ fun ProManage( is SubscriptionType.Expired -> { // the details depend on the loading/error state - val renewIcon: @Composable BoxScope.() -> Unit = { + fun renewIcon(color: Color): @Composable BoxScope.() -> Unit = { Icon( modifier = Modifier.align(Alignment.Center) .size(LocalDimensions.current.iconMedium) .qaTag(R.string.qa_action_item_icon), painter = painterResource(id = R.drawable.ic_circle_plus), contentDescription = null, - tint = LocalColors.current.text + tint = color ) } val (subtitle, subColor, icon) = when(subscriptionRefreshState){ is State.Loading -> Triple Unit>( - //todo PRO need the ellipsis version of this string - Phrase.from(LocalContext.current, R.string.checkingProStatus) + Phrase.from(LocalContext.current, R.string.checkingProStatusEllipsis) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), LocalColors.current.text, @@ -845,12 +845,12 @@ fun ProManage( Phrase.from(LocalContext.current, R.string.errorCheckingProStatus) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), - LocalColors.current.warning, renewIcon + LocalColors.current.warning, renewIcon(LocalColors.current.text) ) is State.Success<*> -> Triple Unit>( null, - LocalColors.current.text, renewIcon + LocalColors.current.text, renewIcon(LocalColors.current.accent) ) } @@ -860,6 +860,8 @@ fun ProManage( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() ), + titleColor = if(subscriptionRefreshState is State.Success ) LocalColors.current.accent + else LocalColors.current.text, subtitle = if(subtitle == null) null else annotatedStringResource(subtitle), subtitleColor = subColor, endContent = { @@ -910,7 +912,7 @@ fun PreviewProSettingsPro( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -944,7 +946,7 @@ fun PreviewProSettingsProLoading( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -978,7 +980,7 @@ fun PreviewProSettingsProError( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -1008,7 +1010,7 @@ fun PreviewProSettingsExpired( type = SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -1037,7 +1039,7 @@ fun PreviewProSettingsExpiredLoading( type = SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -1066,7 +1068,7 @@ fun PreviewProSettingsExpiredError( type = SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 39f4fd840e..982a7afb3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -293,6 +293,7 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusRenewError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() //todo PRO will need to handle never subscribed here 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 80ae0b0f84..11b77926b7 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 @@ -100,7 +100,7 @@ private fun PreviewUpdatePlan( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index bc16f1a34d..dd9b0636b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -220,7 +220,7 @@ private fun PreviewNonOrigExpiredUpdatePlan( subscription = SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt index 8cb7861a4c..ab0947d8ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNonOriginating.kt @@ -132,7 +132,7 @@ private fun PreviewUpdatePlan( ), duration = ProSubscriptionDuration.THREE_MONTHS, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", 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 ba363f5d9d..80face52a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -103,7 +103,7 @@ class ProStatusManager @Inject constructor( ), duration = ProSubscriptionDuration.ONE_MONTH, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -119,7 +119,7 @@ class ProStatusManager @Inject constructor( ), duration = ProSubscriptionDuration.ONE_MONTH, subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", @@ -142,7 +142,7 @@ class ProStatusManager @Inject constructor( DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), subscriptionDetails = SubscriptionDetails( - device = "iPhone", + device = "iOS", store = "Apple App Store", platform = "Apple", platformAccount = "Apple Account", diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 89f5ba76bd..0ae2a835d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +60,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics @@ -70,6 +72,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideSubcomposition @@ -1013,22 +1016,25 @@ fun SessionProSettingsHeader( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) - Row( - modifier = Modifier.height(LocalDimensions.current.smallSpacing) - ) { - Image( - painter = painterResource(R.drawable.ic_session), - contentDescription = null, - colorFilter = ColorFilter.tint(LocalColors.current.text) - ) + // Force the row to remain in LTR to preserve the image+icon order + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = Modifier.height(LocalDimensions.current.smallSpacing) + ) { + Image( + painter = painterResource(R.drawable.ic_session), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.text) + ) - Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) + Spacer(Modifier.width(LocalDimensions.current.xxxsSpacing)) - ProBadge( - colors = proBadgeColorStandard().copy( - backgroundColor = color + ProBadge( + colors = proBadgeColorStandard().copy( + backgroundColor = color + ) ) - ) + } } extraContent?.let{ diff --git a/app/src/main/res/drawable/ic_circle_help.xml b/app/src/main/res/drawable/ic_circle_help.xml index 3b3b13768b..1fba16ac74 100644 --- a/app/src/main/res/drawable/ic_circle_help.xml +++ b/app/src/main/res/drawable/ic_circle_help.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/drawable/ic_question_custom.xml b/app/src/main/res/drawable/ic_question_custom.xml index 148e732324..1f9cfc20e0 100644 --- a/app/src/main/res/drawable/ic_question_custom.xml +++ b/app/src/main/res/drawable/ic_question_custom.xml @@ -1,4 +1,4 @@ - + From bb7193755b65edcab4f8f09e1fb85cb0e859ca8a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 15:19:28 +1100 Subject: [PATCH 061/219] Forced Pro Expired is not Pro --- .../securesms/database/RecipientRepository.kt | 12 ++++++++++-- .../thoughtcrime/securesms/pro/ProStatusManager.kt | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) 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 70d252701e..1bbbd87e5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -47,8 +47,10 @@ import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.model.NotifyType import org.thoughtcrime.securesms.database.model.RecipientSettings +import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupMemberComparator +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import java.lang.ref.WeakReference import java.time.Duration @@ -147,7 +149,10 @@ class RecipientRepository @Inject constructor( value = createLocalRecipient(address, recipientData) changeSource = merge( configFactory.userConfigsChanged(onlyConfigTypes = EnumSet.of(UserConfigType.USER_PROFILE)), - TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } + TextSecurePreferences.events.filter { + it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO + || it == TextSecurePreferences.DEBUG_SUBSCRIPTION_STATUS + } ) } @@ -407,7 +412,10 @@ class RecipientRepository @Inject constructor( expiryMode = configs.userProfile.getNtsExpiry(), priority = configs.userProfile.getNtsPriority(), proStatus = if (preferences.forceCurrentUserAsPro()) { - ProStatus.Pro() + // take into account the fact that we can be expired via the debug menu - which is no longer pro + if(preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED + || preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE) ProStatus.None + else ProStatus.Pro() } else { // TODO: Get pro status from config ProStatus.None 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 80face52a7..947f76d287 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -46,7 +46,10 @@ class ProStatusManager @Inject constructor( (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_PRO_PLAN_STATUS } as Flow<*>) .onStart { emit(Unit) } .map { prefs.getDebugProPlanStatus() }, - ){ selfRecipient, debugSubscription, debugProPlanStatus -> + (TextSecurePreferences.events.filter { it == TextSecurePreferences.SET_FORCE_CURRENT_USER_PRO } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.forceCurrentUserAsPro() }, + ){ selfRecipient, debugSubscription, debugProPlanStatus, forceCurrentUserAsPro -> //todo PRO implement properly val subscriptionState = debugSubscription ?: DebugMenuViewModel.DebugSubscriptionStatus.AUTO_GOOGLE @@ -56,7 +59,8 @@ class ProStatusManager @Inject constructor( else -> State.Success(Unit) } - if(selfRecipient.proStatus is ProStatus.None){ + if(!forceCurrentUserAsPro){ + //todo PRO this is where we should get the real state SubscriptionState( type = SubscriptionType.NeverSubscribed, refreshState = proDataStatus From 0f219c5a97b3bc74327e83c1a3e86d9d4de2b1d8 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 15:45:06 +1100 Subject: [PATCH 062/219] Non-Pro loading and error states --- .../securesms/debugmenu/DebugMenu.kt | 38 +++++++------- .../securesms/preferences/SettingsScreen.kt | 10 +--- .../preferences/SettingsViewModel.kt | 49 ------------------- .../prosettings/ProSettingsHomeScreen.kt | 7 ++- 4 files changed, 26 insertions(+), 78 deletions(-) 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 ad8a6100e5..6be7bb2d63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -277,28 +277,28 @@ fun DebugMenu( ) } ) + } + } - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - Text( - modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), - text = "Pro Data Status", - style = LocalType.current.base - ) - DropDown( - modifier = Modifier.fillMaxWidth() - .padding(top = LocalDimensions.current.xxsSpacing), - selectedText = uiState.selectedDebugProPlanStatus.label, - values = uiState.debugProPlanStatus.map { it.label }, - onValueSelected = { selection -> - sendCommand( - DebugMenuViewModel.Commands.SetDebugProPlanStatus( - uiState.debugProPlanStatus.first { it.label == selection } - ) - ) - } + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + Text( + modifier = Modifier.padding(top = LocalDimensions.current.xxsSpacing), + text = "Pro Data Status", + style = LocalType.current.base + ) + DropDown( + modifier = Modifier.fillMaxWidth() + .padding(top = LocalDimensions.current.xxsSpacing), + selectedText = uiState.selectedDebugProPlanStatus.label, + values = uiState.debugProPlanStatus.map { it.label }, + onValueSelected = { selection -> + sendCommand( + DebugMenuViewModel.Commands.SetDebugProPlanStatus( + uiState.debugProPlanStatus.first { it.label == selection } + ) ) } - } + ) Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) DebugSwitchRow( 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 46abc5eb9e..693d109e32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -561,15 +561,7 @@ fun Buttons( modifier = Modifier.qaTag(R.string.qa_settings_item_pro), colors = accentTextButtonColors() ) { - // there is a special case when we have a subscription error or loading - // but also no pro account - if(subscriptionState.refreshState !is State.Success && - subscriptionState.type is SubscriptionType.NeverSubscribed - ){ - sendCommand(ShowProErrorOrLoading) - } else { - activity?.push() - } + activity?.push() } Divider() 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 d272784213..1c609572be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -585,53 +585,6 @@ class SettingsViewModel @Inject constructor( showUrlDialog( "https://session.foundation/donate#app") } - is Commands.ShowProErrorOrLoading -> { - when(_uiState.value.subscriptionState.refreshState){ - // if we are in a loading or refresh state we should show a dialog instead - is State.Loading -> { - _uiState.update { - it.copy( - showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusLoading)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusLoadingDescription)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - positiveText = context.getString(R.string.okay), - positiveStyleDanger = false, - ) - ) - } - } - - is State.Error -> { - _uiState.update { - it.copy( - showSimpleDialog = SimpleDialogData( - title = Phrase.from(context.getText(R.string.proStatusError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString(), - message = Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format(), - positiveText = context.getString(R.string.retry), - negativeText = context.getString(R.string.helpSupport), - positiveStyleDanger = false, - showXIcon = true, - onPositive = { refreshSubscriptionData() }, - onNegative = { - showUrlDialog(ProStatusManager.URL_PRO_SUPPORT) - } - ) - ) - } - } - - else -> {} - } - } - is Commands.HideSimpleDialog -> { _uiState.update { it.copy(showSimpleDialog = null) } } @@ -718,8 +671,6 @@ class SettingsViewModel @Inject constructor( data object OnDonateClicked: Commands - data object ShowProErrorOrLoading: Commands - data class ClearData(val clearNetwork: Boolean): Commands } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 593ced4eec..d22f35c78c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -131,6 +131,7 @@ fun ProSettingsHome( // add a click handling if the subscription state is loading or errored if(data.subscriptionState.refreshState !is State.Success<*>){ sendCommand(OnHeaderClicked) + //todo PRO double check if KEE is ok to not have two different dialogs for the header vs the action button. If yes then I need to simplify the logic, if not I need to fix the never-subscribed case } else null }, extraHeaderContent = { @@ -146,7 +147,6 @@ fun ProSettingsHome( when(subscriptionType){ is SubscriptionType.Active -> R.string.proStatusLoadingSubtitle else -> R.string.checkingProStatus - //todo PRO will need to handle never subscribed here })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), @@ -193,6 +193,10 @@ fun ProSettingsHome( ) { // Header for non-pro users if(subscriptionType is SubscriptionType.NeverSubscribed) { + if(data.subscriptionState.refreshState !is State.Success){ + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + } + Text( text = Phrase.from(context.getText(R.string.proFullestPotential)) .put(APP_NAME_KEY, stringResource(R.string.app_name)) @@ -207,6 +211,7 @@ fun ProSettingsHome( AccentFillButtonRect( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.theContinue), + enabled = data.subscriptionState.refreshState is State.Success, onClick = { sendCommand(GoToChoosePlan) } ) } From 1c87205b6ce2b9cb3b6e25d3f15a404bd6bd8d45 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 17:16:03 +1100 Subject: [PATCH 063/219] Added more cases to help QA + proper delay from the UI --- .../securesms/database/RecipientRepository.kt | 3 +- .../securesms/debugmenu/DebugMenuViewModel.kt | 12 ++++--- .../securesms/home/HomeDialogs.kt | 36 ++++++++++++++++--- .../securesms/home/HomeViewModel.kt | 4 --- .../securesms/pro/ProStatusManager.kt | 27 ++++++++++++++ .../securesms/ui/ProComponents.kt | 3 ++ 6 files changed, 72 insertions(+), 13 deletions(-) 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 1bbbd87e5b..e10d248e0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientRepository.kt @@ -414,7 +414,8 @@ class RecipientRepository @Inject constructor( proStatus = if (preferences.forceCurrentUserAsPro()) { // take into account the fact that we can be expired via the debug menu - which is no longer pro if(preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED - || preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE) ProStatus.None + || preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE + || preferences.getDebugSubscriptionType() == DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER) ProStatus.None else ProStatus.Pro() } else { // TODO: Get pro status from config 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 5fc01a9cdd..8688a484ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -87,9 +87,11 @@ class DebugMenuViewModel @Inject constructor( debugSubscriptionStatuses = setOf( DebugSubscriptionStatus.AUTO_GOOGLE, DebugSubscriptionStatus.EXPIRING_GOOGLE, + DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER, DebugSubscriptionStatus.AUTO_APPLE, DebugSubscriptionStatus.EXPIRING_APPLE, DebugSubscriptionStatus.EXPIRED, + DebugSubscriptionStatus.EXPIRED_EARLIER, DebugSubscriptionStatus.EXPIRED_APPLE, ), selectedDebugSubscriptionStatus = textSecurePreferences.getDebugSubscriptionType() ?: DebugSubscriptionStatus.AUTO_GOOGLE, @@ -445,11 +447,13 @@ class DebugMenuViewModel @Inject constructor( enum class DebugSubscriptionStatus(val label: String) { AUTO_GOOGLE("Auto Renewing (Google, 3 months)"), - EXPIRING_GOOGLE("Expiring/Cancelled (Google, 12 months)"), + EXPIRING_GOOGLE("Expiring/Cancelled (Expires in 14 days, Google, 12 months)"), + EXPIRING_GOOGLE_LATER("Expiring/Cancelled (Expires in 40 days, Google, 12 months)"), AUTO_APPLE("Auto Renewing (Apple, 1 months)"), - EXPIRING_APPLE("Expiring/Cancelled (Apple, 1 months)"), - EXPIRED("Expired (Google)"), - EXPIRED_APPLE("Expired (Apple)"), + EXPIRING_APPLE("Expiring/Cancelled (Expires in 14 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)"), } enum class DebugProPlanStatus(val label: String){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index b221a45971..31e7c693e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -2,12 +2,18 @@ package org.thoughtcrime.securesms.home import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import com.squareup.phrase.Phrase +import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.NonTranslatableStringConstants.PRO @@ -66,7 +72,18 @@ fun HomeDialogs( ) } - if(dialogsState.proExpiringCTA != null){ + // we need a delay before displaying this. + // Setting the delay in the VM does not account for render and it seems to appear immediately + var showExpiring by remember { mutableStateOf(false) } + LaunchedEffect(dialogsState.proExpiringCTA) { + showExpiring = false + if (dialogsState.proExpiringCTA != null) { + delay(1500) + showExpiring = true + } + } + + if(showExpiring && dialogsState.proExpiringCTA != null){ val context = LocalContext.current AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_generic_bg, @@ -80,9 +97,9 @@ fun HomeDialogs( .format() .toString(), features = listOf( - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), ), positiveButtonText = stringResource(R.string.update), negativeButtonText = stringResource(R.string.close), @@ -96,7 +113,18 @@ fun HomeDialogs( ) } - if(dialogsState.proExpiredCTA){ + // we need a delay before displaying this. + // Setting the delay in the VM does not account for render and it seems to appear immediately + var showExpired by remember { mutableStateOf(false) } + LaunchedEffect(dialogsState.proExpiredCTA) { + showExpired = false + if (dialogsState.proExpiredCTA) { + delay(1500) + showExpired = true + } + } + + if (showExpired && dialogsState.proExpiredCTA) { val context = LocalContext.current AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_generic_bg, @@ -110,9 +138,9 @@ fun HomeDialogs( .format() .toString(), features = listOf( - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), + CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), ), positiveButtonText = stringResource(R.string.renew), negativeButtonText = stringResource(R.string.cancel), diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 4ecd116b9f..6906b3c7ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -9,7 +9,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -50,7 +49,6 @@ import org.thoughtcrime.securesms.util.UserProfileModalData import org.thoughtcrime.securesms.util.UserProfileUtils import org.thoughtcrime.securesms.webrtc.CallManager import org.thoughtcrime.securesms.webrtc.data.State -import java.time.Duration import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject @@ -172,7 +170,6 @@ class HomeViewModel @Inject constructor( val validUntil = subscription.type.proStatus.validUntil ?: return@collect if (validUntil.isBefore(now.plus(7, ChronoUnit.DAYS))) { - delay(2000) prefs.setHasSeenProExpiring() _dialogsState.update { state -> state.copy( @@ -191,7 +188,6 @@ class HomeViewModel @Inject constructor( // Check if now is within 30 days after expiry if (now.isBefore(validUntil.plus(30, ChronoUnit.DAYS))) { - delay(2000) prefs.setHasSeenProExpired() _dialogsState.update { state -> state.copy(proExpiredCTA = true) 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 947f76d287..a71d352678 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -100,6 +100,22 @@ class ProStatusManager @Inject constructor( ) ) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRING_GOOGLE_LATER -> SubscriptionType.Active.Expiring( + proStatus = ProStatus.Pro( + visible = true, + validUntil = Instant.now() + Duration.ofDays(40), + ), + duration = ProSubscriptionDuration.TWELVE_MONTHS, + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) + ) + DebugMenuViewModel.DebugSubscriptionStatus.AUTO_APPLE -> SubscriptionType.Active.AutoRenewing( proStatus = ProStatus.Pro( visible = true, @@ -143,6 +159,17 @@ class ProStatusManager @Inject constructor( refundUrl = "https://getsession.org/android-refund", ) ) + DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_EARLIER -> SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(60), + subscriptionDetails = SubscriptionDetails( + device = "Android", + store = "Google Play Store", + platform = "Google", + platformAccount = "Google account", + subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", + refundUrl = "https://getsession.org/android-refund", + ) + ) DebugMenuViewModel.DebugSubscriptionStatus.EXPIRED_APPLE -> SubscriptionType.Expired( expiredAt = Instant.now() - Duration.ofDays(14), subscriptionDetails = SubscriptionDetails( diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 0ae2a835d2..7dfd639686 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -231,6 +231,7 @@ fun SessionProCTA( textContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, title: String = stringResource(R.string.upgradeTo), + titleColor: Color = LocalColors.current.text, badgeAtStart: Boolean = false, disabled: Boolean = false, features: List = emptyList(), @@ -281,6 +282,7 @@ fun SessionProCTA( ProBadgeText( modifier = Modifier.align(Alignment.CenterHorizontally), text = title, + textStyle = LocalType.current.h5.copy(color = titleColor), badgeAtStart = badgeAtStart, badgeColors = if (disabled) proBadgeColorDisabled() else proBadgeColorStandard(), ) @@ -494,6 +496,7 @@ fun AnimatedSessionProCTA( positiveButtonText = positiveButtonText, negativeButtonText = negativeButtonText, title = title, + titleColor = if(disabled) LocalColors.current.disabled else LocalColors.current.text, badgeAtStart = badgeAtStart, disabled = disabled ) From 19c6267179a81bb794199ed7576928dcc3557d2c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 22 Oct 2025 18:11:05 +1100 Subject: [PATCH 064/219] Making sure the content is clickable --- .../prosettings/BaseProSettingsScreens.kt | 13 +++++++++++++ .../prosettings/chooseplan/ChoosePlanNoBilling.kt | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index d01c4120ac..ec7c688348 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.preferences.prosettings import androidx.annotation.DrawableRes import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier @@ -207,6 +210,7 @@ fun BaseNonOriginatingProSettingsScreen( headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, + contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { @@ -229,6 +233,15 @@ fun BaseNonOriginatingProSettingsScreen( if (contentDescription != null) { Spacer(Modifier.height(LocalDimensions.current.xxxsSpacing)) Text( + modifier = Modifier.then( + // make the component clickable is there is an action + if (contentClick != null) Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = contentClick + ) + else Modifier + ), text = annotatedStringResource(contentDescription), style = LocalType.current.base, color = LocalColors.current.text, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt index dd9b0636b8..6096804e94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanNoBilling.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.C import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.components.iconExternalLink import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider @@ -203,6 +204,9 @@ fun ChoosePlanNoBilling( }, contentTitle = contentTitle, contentDescription = contentDescription, + contentClick = { + sendCommand(ShowOpenUrlDialog("https://getsession.org/pro-roadmap")) + }, linkCellsInfo = cellsInfo, linkCells = cells ) From eaf6f9846905095b33b5765171d25a2af216275a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 11:21:46 +1100 Subject: [PATCH 065/219] Updated to the latest BOM --- 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 1cd175d618..49a4f171e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" cameraCamera2Version = "1.5.0" cardviewVersion = "1.0.0" -composeBomVersion = "2025.09.01" +composeBomVersion = "2025.10.01" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" From 7284f8c4af5f71e388172eadf1dba5ef1d303016 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 11:21:59 +1100 Subject: [PATCH 066/219] SES-4767 - zoom handling in QR code scanning --- .../securesms/ui/components/QR.kt | 90 ++++++++++++++----- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt index 8a2b3e26bd..a0454ff985 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/QR.kt @@ -10,8 +10,11 @@ import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController import androidx.camera.view.PreviewView import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,6 +41,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -163,32 +167,42 @@ fun QRScannerScreen( @Composable fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { - val localContext = LocalContext.current - val cameraProvider = remember { ProcessCameraProvider.getInstance(localContext) } - - val preview = Preview.Builder().build() - val selector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - - runCatching { - cameraProvider.get().unbindAll() - - cameraProvider.get().bindToLifecycle( - LocalLifecycleOwner.current, - selector, - preview, - buildAnalysisUseCase(QRCodeReader(), onScan) - ) - - }.onFailure { Log.e(TAG, "error binding camera", it) } + val context = LocalContext.current + + // Setting up camera objects + val lifecycleOwner = LocalLifecycleOwner.current + val controller = remember { + LifecycleCameraController(context).apply { + setEnabledUseCases(CameraController.IMAGE_ANALYSIS) + setTapToFocusEnabled(true) + setPinchToZoomEnabled(true) + } + } - DisposableEffect(cameraProvider) { + DisposableEffect(Unit) { + val executor = Executors.newSingleThreadExecutor() + controller.setImageAnalysisAnalyzer(executor, QRCodeAnalyzer(QRCodeReader(), onScan)) onDispose { - cameraProvider.get().unbindAll() + controller.clearImageAnalysisAnalyzer() + executor.shutdown() } } + LaunchedEffect(controller, lifecycleOwner) { + controller.bindToLifecycle(lifecycleOwner) + controller.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PreviewView(ctx).apply { + controller.let { this.controller = it } + } + } + ) + + val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -224,12 +238,24 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { } } ) { padding -> - Box { + var cachedZoom by remember { mutableStateOf(1f) } + + val zoomRange = controller.zoomState.value?.let { + it.minZoomRatio..it.maxZoomRatio + } ?: 1f..4f + + Box(Modifier.fillMaxSize() + .padding(padding)) { AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { PreviewView(it).apply { preview.setSurfaceProvider(surfaceProvider) } } + modifier = Modifier.matchParentSize(), + factory = { ctx -> + PreviewView(ctx).apply { + this.controller = controller + } + } ) + // visual cue for middle part Box( Modifier .aspectRatio(1f) @@ -238,6 +264,22 @@ fun ScanQrCode(errors: Flow, onScan: (String) -> Unit) { .background(Color(0x33ffffff)) .align(Alignment.Center) ) + + // Fullscreen overlay that captures gestures and updates camera zoom + // Without this, the bottom sheet in start-conversation, or the viewpagers + // all fight for gesture handling and the zoom doesn't work + Box( + Modifier + .matchParentSize() + .pointerInput(controller) { + detectTransformGestures { _, _, zoom, _ -> + val new = (cachedZoom * zoom) + .coerceIn(zoomRange.start, zoomRange.endInclusive) + cachedZoom = new + controller.cameraControl?.setZoomRatio(new) + } + } + ) } } } From b318c9f19d79fc06db70af9b7dbf9d5c59ee8e33 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 11:30:21 +1100 Subject: [PATCH 067/219] SES-4768 - moderators can "delete for everyone" in a community --- .../securesms/conversation/v2/ConversationViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 21113c8bc3..0d0ee140fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -225,6 +225,10 @@ class ConversationViewModel @AssistedInject constructor( it.currentUserRole in EnumSet.of(GroupMemberRole.ADMIN, GroupMemberRole.HIDDEN_ADMIN) } + val canModerate: StateFlow = recipientFlow.mapStateFlow(viewModelScope) { + it.currentUserRole.canModerate + } + private val _searchOpened = MutableStateFlow(false) val appBarData: StateFlow = combine( @@ -750,7 +754,7 @@ class ConversationViewModel @AssistedInject constructor( } // If the user is an admin or is interacting with their own message And are allowed to delete for everyone - (isAdmin.value || allSentByCurrentUser) && canDeleteForEveryone -> { + (canModerate.value || allSentByCurrentUser) && canDeleteForEveryone -> { _dialogsState.update { it.copy( deleteEveryone = DeleteForEveryoneDialogData( From 657928cc77f4f4d6005faee8a87990c1857e268c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 11:55:23 +1100 Subject: [PATCH 068/219] SES-2740 - Do not show indicators from others when the toggle is off --- .../messaging/sending_receiving/ReceivedMessageHandler.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index cd04dec911..b45e0b2a2b 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -53,6 +53,7 @@ import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.MessageType import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.RecipientData @@ -100,6 +101,7 @@ class ReceivedMessageHandler @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val configFactory: ConfigFactoryProtocol, private val messageRequestResponseHandler: Provider, + private val prefs: TextSecurePreferences, ) { suspend fun handle( @@ -183,6 +185,9 @@ class ReceivedMessageHandler @Inject constructor( } private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + // We don't want to show other people's indicators if the toggle is off + if(!prefs.isTypingIndicatorsEnabled()) return + val address = Address.fromSerialized(senderPublicKey) val threadID = storage.getThreadId(address) ?: return typingIndicators.didReceiveTypingStartedMessage(threadID, address, 1) From 5df0d79f1db8f832ecc68953835da131cb96f5a3 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 12:57:09 +1100 Subject: [PATCH 069/219] Added a typing indicatorin the preference setting --- .../TypingIndicatorPreferenceCompat.kt | 81 +++++++++++++++++++ .../TypingIndicatorViewContainer.kt | 7 +- .../layout/typing_indicator_preference.xml | 23 ++++++ app/src/main/res/xml/preferences_privacy.xml | 4 +- 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt create mode 100644 app/src/main/res/layout/typing_indicator_preference.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt new file mode 100644 index 0000000000..fc597a7894 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingIndicatorPreferenceCompat.kt @@ -0,0 +1,81 @@ +package org.thoughtcrime.securesms.components + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.preference.PreferenceViewHolder +import androidx.preference.TwoStatePreference +import kotlinx.coroutines.flow.MutableStateFlow +import network.loki.messenger.R +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY +import org.thoughtcrime.securesms.conversation.v2.components.TypingIndicatorViewContainer +import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.getSubbedCharSequence +import org.thoughtcrime.securesms.ui.setThemedContent + +class TypingIndicatorPreferenceCompat : TwoStatePreference { + private var listener: OnPreferenceClickListener? = null + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs, androidx.preference.R.attr.switchPreferenceCompatStyle) + constructor(context: Context) : this(context, null, androidx.preference.R.attr.switchPreferenceCompatStyle) + + private val checkState = MutableStateFlow(isChecked) + private val enableState = MutableStateFlow(isEnabled) + + init { + widgetLayoutResource = R.layout.typing_indicator_preference + } + + override fun setChecked(checked: Boolean) { + super.setChecked(checked) + + checkState.value = checked + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + enableState.value = enabled + } + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + + val composeView = holder.findViewById(R.id.compose_preference) as ComposeView + composeView.setThemedContent { + SessionSwitch( + checked = checkState.collectAsState().value, + onCheckedChange = null, + enabled = isEnabled + ) + } + + val typingView = holder.findViewById(R.id.pref_typing_indicator_view) as TypingIndicatorViewContainer + typingView.apply { + startAnimation() + + // stop animation if the preference row is detached + holder.itemView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) = Unit + override fun onViewDetachedFromWindow(v: View) { + stopAnimation() + v.removeOnAttachStateChangeListener(this) + } + }) + } + } + + override fun setOnPreferenceClickListener(listener: OnPreferenceClickListener?) { + this.listener = listener + } + + override fun onClick() { + if (listener == null || !listener!!.onPreferenceClick(this)) { + super.onClick() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt index f9a10c602d..e362531fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/components/TypingIndicatorViewContainer.kt @@ -19,7 +19,10 @@ class TypingIndicatorViewContainer : LinearLayout { } fun setTypists(typists: List
) { - if (typists.isEmpty()) { binding.typingIndicator.root.stopAnimation(); return } - binding.typingIndicator.root.startAnimation() + if (typists.isEmpty()) { stopAnimation(); return } + startAnimation() } + + fun startAnimation() = binding.typingIndicator.root.startAnimation() + fun stopAnimation() = binding.typingIndicator.root.stopAnimation() } \ No newline at end of file diff --git a/app/src/main/res/layout/typing_indicator_preference.xml b/app/src/main/res/layout/typing_indicator_preference.xml new file mode 100644 index 0000000000..a7754d36b3 --- /dev/null +++ b/app/src/main/res/layout/typing_indicator_preference.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_privacy.xml b/app/src/main/res/xml/preferences_privacy.xml index ab5449e31d..eaed62b9b1 100644 --- a/app/src/main/res/xml/preferences_privacy.xml +++ b/app/src/main/res/xml/preferences_privacy.xml @@ -36,13 +36,11 @@ - - - From 2da5c43f3f601b39be1bb49c2f82346f39a6dc37 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 13:08:34 +1100 Subject: [PATCH 070/219] bumping code for dev --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d769563e80..f0b0765605 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,7 +27,7 @@ configurations.configureEach { } val canonicalVersionCode = 427 -val canonicalVersionName = "1.28.2" +val canonicalVersionName = "1.30.0" val postFixSize = 10 val abiPostFix = mapOf( From f733aefc18615dcc973888ac1472eaaad009f322 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 16:11:53 +1100 Subject: [PATCH 071/219] Making the pro setting app bar react to scroll by making its bg opaque --- .../prosettings/BaseProSettingsScreens.kt | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index ec7c688348..9414539742 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.preferences.prosettings import androidx.annotation.DrawableRes +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -20,31 +22,35 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import network.loki.messenger.R import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource +import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -52,9 +58,6 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold -import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.DialogBg -import org.thoughtcrime.securesms.ui.components.inlineContentMap /** * Base structure used in most Pro Settings screen @@ -69,42 +72,64 @@ fun BaseProSettingsScreen( extraHeaderContent: @Composable (() -> Unit)? = null, content: @Composable () -> Unit ){ + // We need the app bar to start as transparent and slowly go opaque as we scroll + val lazyListState = rememberLazyListState() + // Calculate scroll fraction + val density = LocalDensity.current + val thresholdPx = remember { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque + + // raw fraction 0..1 derived from scrolling + val rawFraction by remember { + derivedStateOf { + when { + lazyListState.layoutInfo.totalItemsCount == 0 -> 0f + lazyListState.firstVisibleItemIndex > 0 -> 1f + else -> (lazyListState.firstVisibleItemScrollOffset / thresholdPx).coerceIn(0f, 1f) + } + } + } + + // easing + smoothing of fraction + val easedFraction = remember(rawFraction) { + FastOutSlowInEasing.transform(rawFraction) + } + + // setting the appbar's bg alpha based on scroll + val backgroundColor = LocalColors.current.background.copy(alpha = easedFraction) + Scaffold( topBar = if(!hideHomeAppBar){{ BackAppBar( title = "", - backgroundColor = Color.Transparent, + backgroundColor = backgroundColor, onBack = onBack, ) }} else {{}}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - Column( + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(top = - (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp)) .consumeWindowInsets(paddings) - .padding( - horizontal = LocalDimensions.current.spacing, - ) - .verticalScroll(rememberScrollState()), + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), horizontalAlignment = CenterHorizontally ) { - Spacer(Modifier.height(46.dp)) - - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) - - content() + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) + } - Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) - Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars)) + item { content() } } } } From b93c3129381e79475ce5bda14fed68adaec72bf9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 23 Oct 2025 17:07:58 +1100 Subject: [PATCH 072/219] Fixing up the activity observer --- .../securesms/util/CurrentActivityObserver.kt | 8 ++++---- .../securesms/reviews/PlayStoreReviewManager.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt index 3408057bd7..b4006d9fda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrentActivityObserver.kt @@ -25,18 +25,18 @@ class CurrentActivityObserver @Inject constructor( init { application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} - override fun onActivityStarted(activity: Activity) { + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) { _currentActivity.value = activity Log.d("CurrentActivityObserver", "Current activity set to: ${activity.javaClass.simpleName}") } - override fun onActivityResumed(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) { + override fun onActivityPaused(activity: Activity) { if (_currentActivity.value === activity) { _currentActivity.value = null Log.d("CurrentActivityObserver", "Current activity set to null") } } + override fun onActivityStopped(activity: Activity) {} override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} override fun onActivityDestroyed(activity: Activity) {} }) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt index a28a203158..41d5f79e06 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/reviews/PlayStoreReviewManager.kt @@ -38,7 +38,7 @@ class PlayStoreReviewManager @Inject constructor( manager.launchReview(activity, info) val hasLaunchedSomething = withTimeoutOrNull(500.milliseconds) { - currentActivityObserver.currentActivity.first { it != requestedOnActivity } + currentActivityObserver.currentActivity.first { it != null && it != requestedOnActivity } } != null require(hasLaunchedSomething) { From 9c798530f189149f6534e5de2c1e0565b93f26f2 Mon Sep 17 00:00:00 2001 From: jbsession Date: Thu, 23 Oct 2025 17:20:32 +0800 Subject: [PATCH 073/219] SES - 4683 : UI Artifact When Opening New Conversation (#1628) * Add own inset listener to fab * new message fab inset * Update typemask for inset * Revert "Update typemask for inset" This reverts commit adda37328b27777fd16ec11da9496f25478b860c. * updated safe insets margins * Fix typemas * Consume inset false --- .../securesms/home/HomeActivity.kt | 32 ++++++++++++------- .../securesms/util/ViewUtilities.kt | 3 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 33f91e5a8f..4652433666 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -18,6 +18,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding @@ -86,13 +89,13 @@ import org.thoughtcrime.securesms.reviews.ui.InAppReview import org.thoughtcrime.securesms.reviews.ui.InAppReviewViewModel import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager -import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.setThemedContent import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.util.AvatarUtils import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.applySafeInsetsMargins import org.thoughtcrime.securesms.util.applySafeInsetsPaddings import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.fadeIn @@ -415,16 +418,6 @@ class HomeActivity : ScreenLockActionBarActivity(), } } - binding.root.applySafeInsetsPaddings( - applyBottom = false, - alsoApply = { insets -> - binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) - binding.newConversationButton.updateLayoutParams { - bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset) - } - } - ) - // Set up in-app review binding.inAppReviewView.setThemedContent { InAppReview( @@ -433,6 +426,8 @@ class HomeActivity : ScreenLockActionBarActivity(), sendCommands = inAppReviewViewModel::sendUiCommand, ) } + + applyViewInsets() } override fun onCancelClicked() { @@ -893,6 +888,21 @@ class HomeActivity : ScreenLockActionBarActivity(), private fun showStartConversation() { homeViewModel.onCommand(HomeViewModel.Commands.ShowStartConversationSheet) } + + private fun applyViewInsets() { + binding.root.applySafeInsetsPaddings( + applyBottom = false, + consumeInsets = false, + alsoApply = { insets -> + binding.globalSearchRecycler.updatePadding(bottom = insets.bottom) + } + ) + + binding.newConversationButton.applySafeInsetsMargins( + typeMask = WindowInsetsCompat.Type.navigationBars(), + additionalInsets = Insets.of(0,0,0, resources.getDimensionPixelSize(R.dimen.new_conversation_button_bottom_offset)) + ) + } } fun Context.startHomeActivity(isFromOnboarding: Boolean, isNewAccount: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt index 7b965f2e3c..9703ab1bf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtilities.kt @@ -154,6 +154,7 @@ fun View.applySafeInsetsMargins( consumeInsets: Boolean = true, @InsetsType typeMask: Int = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(), + additionalInsets : Insets = Insets.NONE // for additional offsets ) { ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> // Get system bars insets @@ -162,7 +163,7 @@ fun View.applySafeInsetsMargins( // Update view margins to account for system bars val lp = view.layoutParams as? MarginLayoutParams if (lp != null) { - lp.setMargins(systemBarsInsets.left, systemBarsInsets.top, systemBarsInsets.right, systemBarsInsets.bottom) + lp.setMargins(additionalInsets.left + systemBarsInsets.left, additionalInsets.top + systemBarsInsets.top, additionalInsets.right + systemBarsInsets.right, additionalInsets.bottom + systemBarsInsets.bottom) view.layoutParams = lp if (consumeInsets) { From ab2d7b2ec5aa5c631287e3f98f6c33fabc07a232 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 24 Oct 2025 09:29:44 +1100 Subject: [PATCH 074/219] Only listen to the state within the lifecycle --- .../main/java/org/thoughtcrime/securesms/home/HomeActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 33f91e5a8f..2bd77b727a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -22,6 +22,7 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager @@ -290,7 +291,7 @@ class HomeActivity : ScreenLockActionBarActivity(), binding.dialogs.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setThemedContent { - val dialogsState by homeViewModel.dialogsState.collectAsState() + val dialogsState by homeViewModel.dialogsState.collectAsStateWithLifecycle() HomeDialogs( dialogsState = dialogsState, sendCommand = homeViewModel::onCommand From 2d2862b7645e089aabd5af8d953e66db9d0ae93e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 24 Oct 2025 09:32:03 +1100 Subject: [PATCH 075/219] Mark CTA as seen on dismiss --- .../java/org/thoughtcrime/securesms/home/HomeViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index 6906b3c7ed..c969576b01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -170,7 +170,6 @@ class HomeViewModel @Inject constructor( val validUntil = subscription.type.proStatus.validUntil ?: return@collect if (validUntil.isBefore(now.plus(7, ChronoUnit.DAYS))) { - prefs.setHasSeenProExpiring() _dialogsState.update { state -> state.copy( proExpiringCTA = ProExpiringCTA( @@ -188,7 +187,7 @@ class HomeViewModel @Inject constructor( // Check if now is within 30 days after expiry if (now.isBefore(validUntil.plus(30, ChronoUnit.DAYS))) { - prefs.setHasSeenProExpired() + _dialogsState.update { state -> state.copy(proExpiredCTA = true) } @@ -299,10 +298,12 @@ class HomeViewModel @Inject constructor( } is Commands.HideExpiringCTADialog -> { + prefs.setHasSeenProExpiring() _dialogsState.update { it.copy(proExpiringCTA = null) } } is Commands.HideExpiredCTADialog -> { + prefs.setHasSeenProExpired() _dialogsState.update { it.copy(proExpiredCTA = false) } } From 08dcc04637e26facf2d260170ed88a251f2b3713 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 11:56:48 +1100 Subject: [PATCH 076/219] Loading state in choose plan + store data handling --- app/build.gradle.kts | 3 +- .../prosettings/BaseProSettingsScreens.kt | 87 ++++++++++++--- .../prosettings/ProSettingsViewModel.kt | 30 +++++- .../chooseplan/ChoosePlanScreen.kt | 1 + .../subscription/NoOpSubscriptionManager.kt | 9 +- .../pro/subscription/SubscriptionManager.kt | 14 ++- .../components/CircularProgressIndicator.kt | 12 ++- .../PlayStoreSubscriptionManager.kt | 101 +++++++++++++++--- 8 files changed, 218 insertions(+), 39 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d769563e80..da96b2db71 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -203,11 +203,10 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix(".debug") + setAuthorityPostfix("") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 9414539742..661dc14976 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -33,21 +33,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.consumeAllChanges +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.AnimateFade import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.components.inlineContentMap @@ -57,6 +69,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold /** @@ -66,6 +79,7 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun BaseProSettingsScreen( disabled: Boolean, + loading: Boolean = false, hideHomeAppBar: Boolean = false, onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, @@ -108,28 +122,63 @@ fun BaseProSettingsScreen( contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - LazyColumn( + Box( modifier = Modifier .fillMaxSize() - .consumeWindowInsets(paddings) - .padding(horizontal = LocalDimensions.current.spacing), - state = lazyListState, - contentPadding = PaddingValues( - top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp) + 46.dp, - bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing - ), - horizontalAlignment = CenterHorizontally ) { - item { - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(paddings) + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), + horizontalAlignment = CenterHorizontally + ) { + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) + } + + item { content() } } + } - item { content() } + AnimateFade(loading) { + val loadingLabel = stringResource(R.string.loading) + Box( + modifier = Modifier + .fillMaxSize() + // dim the background so it's visually obvious it's blocked (optional) + .background(blackAlpha40) + // Intercept click events so that when the loading is on we can't click beneath + .pointerInput(loading) { + if (loading) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { it.consume() } + } + } + } + } + // Provide proper a11y semantics: announce an indeterminate progress. + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate + contentDescription = loadingLabel + liveRegion = LiveRegionMode.Polite + }, + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } } } } @@ -146,10 +195,12 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, + loading: Boolean = false, content: @Composable () -> Unit ) { BaseProSettingsScreen( disabled = disabled, + loading = loading, onBack = onBack, ) { Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -235,12 +286,14 @@ fun BaseNonOriginatingProSettingsScreen( headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, + loading: Boolean = false, contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { BaseCellButtonProSettingsScreen( disabled = disabled, + loading = loading, onBack = onBack, buttonText = buttonText, dangerButton = dangerButton, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 982a7afb3b..af27719d47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.content.Context import android.content.Intent import android.icu.util.MeasureUnit +import android.widget.Toast import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -83,6 +84,27 @@ class ProSettingsViewModel @AssistedInject constructor( } } + // observe purchase events + viewModelScope.launch { + subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> + _choosePlanState.update { it.copy(loading = false) } + + when(purchaseEvent){ + is SubscriptionManager.PurchaseEvent.Success -> { + navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) + } + + is SubscriptionManager.PurchaseEvent.Failed -> { + Toast.makeText( + context, + purchaseEvent.errorMessage ?: context.getString(R.string.errorGeneric), + Toast.LENGTH_SHORT + ).show() + } + } + } + } + // Update choosePlanState whenever proSettingsUIState changes viewModelScope.launch { _proSettingsUIState @@ -197,7 +219,7 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private fun generateState(subscriptionState: SubscriptionState){ + private suspend fun generateState(subscriptionState: SubscriptionState){ //todo PRO need to properly calculate this val subType = subscriptionState.type @@ -389,7 +411,6 @@ class ProSettingsViewModel @AssistedInject constructor( Commands.GetProPlan -> { val currentSubscription = _proSettingsUIState.value.subscriptionState.type - if(currentSubscription is SubscriptionType.Active){ val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() @@ -555,6 +576,10 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getPlanFromProvider(){ + _choosePlanState.update { + it.copy(loading = true) + } + subscriptionCoordinator.getCurrentManager().purchasePlan( getSelectedPlan().durationType ) @@ -609,6 +634,7 @@ class ProSettingsViewModel @AssistedInject constructor( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, val hasBillingCapacity: Boolean = false, val hasValidSubscription: Boolean = false, + val loading: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 7681c84c08..8e3140e21a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -92,6 +92,7 @@ fun ChoosePlan( ) { BaseProSettingsScreen( disabled = false, + loading = planData.loading, onBack = onBack, ) { // Keeps track of the badge height dynamically so we can adjust the padding accordingly diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 91a7c8877f..91305b858e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,5 +1,10 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject /** @@ -20,7 +25,9 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val availablePlans: List get() = emptyList() - override fun hasValidSubscription(productId: String): Boolean { + override val purchaseEvents: SharedFlow = MutableSharedFlow() + + override suspend fun hasValidSubscription(productId: String): Boolean { return false } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ab32095926..49293eda1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -1,5 +1,9 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.time.Instant @@ -20,6 +24,14 @@ interface SubscriptionManager: OnAppStartupComponent { val availablePlans: List + sealed interface PurchaseEvent { + data object Success : PurchaseEvent + data class Failed(val errorMessage: String? = null) : PurchaseEvent + } + + // purchase events + val purchaseEvents: SharedFlow + fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) /** @@ -32,6 +44,6 @@ interface SubscriptionManager: OnAppStartupComponent { /** * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API */ - fun hasValidSubscription(productId: String): Boolean + suspend fun hasValidSubscription(productId: String): Boolean } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt index 5fa28c633a..8e9ea6b2c2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/CircularProgressIndicator.kt @@ -5,7 +5,10 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.contentDescription @Composable fun CircularProgressIndicator( @@ -13,7 +16,8 @@ fun CircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(40.dp), + modifier = modifier.size(40.dp) + .contentDescription(stringResource(R.string.loading)), color = color ) } @@ -24,7 +28,8 @@ fun SmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(20.dp), + modifier = modifier.size(20.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) @@ -36,7 +41,8 @@ fun ExtraSmallCircularProgressIndicator( color: Color = LocalContentColor.current ) { androidx.compose.material3.CircularProgressIndicator( - modifier = modifier.size(16.dp), + modifier = modifier.size(16.dp) + .contentDescription(stringResource(R.string.loading)), color = color, strokeWidth = 2.dp ) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 54e7bd96c1..777628e13d 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -7,16 +7,25 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.Purchase import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant import javax.inject.Inject @@ -41,10 +50,32 @@ class PlayStoreSubscriptionManager @Inject constructor( override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" + private val _purchaseEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() + private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + for (purchase in purchases) { + scope.launch { + // signal that purchase was completed + + //todo PRO send confirmation to libsession + _purchaseEvents.emit(PurchaseEvent.Success) + } + } + } else if (result.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) { + Log.w(TAG, "Purchase failed or cancelled: $result") + scope.launch { + _purchaseEvents.emit(PurchaseEvent.Failed()) + } + } } .enableAutoServiceReconnection() .enablePendingPurchases( @@ -94,23 +125,43 @@ class PlayStoreSubscriptionManager @Inject constructor( "Unable to find a plan with id $planId" } - val billingResult = billingClient.launchBillingFlow( - activity, BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerDetails.offerToken) - .build() - ) + // Check for existing subscription + val existingPurchase = getExistingSubscription() + + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) + .build() ) - .build() + ) + + // If user has an existing subscription, configure upgrade/downgrade + if (existingPurchase != null) { + Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + + billingFlowParamsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(existingPurchase.purchaseToken) + // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews + // This applies whether the subscription is auto-renewing or canceled + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION + ) + .build() + ) + } + + val billingResult = billingClient.launchBillingFlow( + activity, + billingFlowParamsBuilder.build() ) check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" } - } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -144,8 +195,32 @@ class PlayStoreSubscriptionManager @Inject constructor( }) } - override fun hasValidSubscription(productId: String): Boolean { - return true //todo PRO implement properly - we should check if the api has a valid subscription matching this productId for the current google user on this phone + /** + * Gets the user's existing active subscription if one exists. + * Returns null if no active subscription is found. + */ + private suspend fun getExistingSubscription(): Purchase? { + return try { + val params = QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.SUBS) + .build() + + val result = billingClient.queryPurchasesAsync(params) + + // Return the first active subscription + result.purchasesList.firstOrNull { + it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? + } + } catch (e: Exception) { + Log.e(TAG, "Error querying existing subscription", e) + null + } + } + + override suspend fun hasValidSubscription(productId: String): Boolean { + // if in debug mode, always return true + return if(prefs.forceCurrentUserAsPro()) true + else getExistingSubscription() != null } companion object { From fd22a95385b6c30379e6eb8e77a4f8cf894e68f7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 14:42:28 +1100 Subject: [PATCH 077/219] Adding a canceled state which is neither an error nor a success in terms of handling billing responses Cleaned up billing access logic as well --- .../utilities/TextSecurePreferences.kt | 1 + .../prosettings/ProSettingsViewModel.kt | 18 ++++--- .../chooseplan/UpdatePlanScreen.kt | 2 +- .../subscription/NoOpSubscriptionManager.kt | 7 +-- .../pro/subscription/SubscriptionManager.kt | 4 +- .../thoughtcrime/securesms/ui/AlertDialog.kt | 1 + .../PlayStoreSubscriptionManager.kt | 47 ++++++++++++++++--- 7 files changed, 62 insertions(+), 18 deletions(-) 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 da8d6afb36..f840572d8e 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -1789,6 +1789,7 @@ class AppTextSecurePreferences @Inject constructor( override fun setDebugForceNoBilling(hasBilling: Boolean) { setBooleanPreference(TextSecurePreferences.DEBUG_FORCE_NO_BILLING, hasBilling) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) } override fun getSubscriptionProvider(): String? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index af27719d47..8eeca29256 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -101,14 +102,19 @@ class ProSettingsViewModel @AssistedInject constructor( Toast.LENGTH_SHORT ).show() } + + is SubscriptionManager.PurchaseEvent.Cancelled -> { + // nothing to do in this case + } } } } - // Update choosePlanState whenever proSettingsUIState changes + // Update choosePlanState whenever proSettingsUIState or the billing support change viewModelScope.launch { - _proSettingsUIState - .map { proState -> + combine(_proSettingsUIState, + subscriptionCoordinator.getCurrentManager().supportsBilling + ) { proState, supportsBilling -> val subType = proState.subscriptionState.type val isActive = subType is SubscriptionType.Active val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS @@ -118,7 +124,7 @@ class ProSettingsViewModel @AssistedInject constructor( ChoosePlanState( subscriptionType = subType, hasValidSubscription = proState.hasValidSubscription, - hasBillingCapacity = proState.hasBillingCapacity, + hasBillingCapacity = supportsBilling, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = listOf( ProPlan( @@ -225,11 +231,12 @@ class ProSettingsViewModel @AssistedInject constructor( val subType = subscriptionState.type _proSettingsUIState.update { + Log.w("", " *** SETTING VM TO: ${subscriptionCoordinator.getCurrentManager().supportsBilling.value}") + ProSettingsState( subscriptionState = subscriptionState, //todo PRO need to get the product id from libsession - also this might be a long running operation hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), - hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling, subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -624,7 +631,6 @@ class ProSettingsViewModel @AssistedInject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: State = State.Loading, - val hasBillingCapacity: Boolean = false, // true is the current build flavour supports billing val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt index 38adba3087..6ccfd0edee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt @@ -31,7 +31,7 @@ fun UpdatePlanScreen( // or we have no billing APIs subscription.subscriptionDetails.isFromAnotherPlatform() || !planData.hasValidSubscription - || !subscriptionManager.supportsBilling -> + || !subscriptionManager.supportsBilling.value -> ChoosePlanNonOriginating( subscription = planData.subscriptionType as SubscriptionType.Active, sendCommand = viewModel::onCommand, diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 91305b858e..d94896898a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,22 +1,23 @@ package org.thoughtcrime.securesms.pro.subscription import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.emptyFlow import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject +import javax.inject.Singleton /** * An implementation representing a lack of support for subscription */ +@Singleton class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val id = "noop" override val name = "" override val description = "" override val iconRes = null - override val supportsBilling: Boolean = false + override val supportsBilling = MutableStateFlow(false) override val quickRefundExpiry = null override val quickRefundUrl = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 49293eda1e..bd86cc4069 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.pro.subscription import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import java.time.Instant @@ -16,7 +17,7 @@ interface SubscriptionManager: OnAppStartupComponent { val description: String val iconRes: Int? - val supportsBilling: Boolean + val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url val quickRefundExpiry: Instant? @@ -26,6 +27,7 @@ interface SubscriptionManager: OnAppStartupComponent { sealed interface PurchaseEvent { data object Success : PurchaseEvent + data object Cancelled : PurchaseEvent data class Failed(val errorMessage: String? = null) : PurchaseEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index ea1077065e..4a10bee2e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -450,6 +450,7 @@ fun TCPolicyDialog( onDismissRequest = onDismissRequest, title = stringResource(R.string.urlOpen), text = stringResource(R.string.urlOpenBrowser), + showCloseButton = true, content = { Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) Cell( diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 777628e13d..f593479208 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -17,9 +17,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.session.libsession.utilities.TextSecurePreferences @@ -29,10 +39,12 @@ import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseE import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant import javax.inject.Inject +import javax.inject.Singleton /** * The Google Play Store implementation of our subscription manager */ +@Singleton class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, @param:ManagerScope private val scope: CoroutineScope, @@ -44,8 +56,19 @@ class PlayStoreSubscriptionManager @Inject constructor( override val description = "" override val iconRes = null - override val supportsBilling: Boolean - get() = !prefs.getDebugForceNoBilling() + // specifically test the google play billing + private val _playBillingAvailable = MutableStateFlow(false) + + // generic billing support method. Uses the property above and also checks the debug pref + override val supportsBilling: StateFlow = combine( + _playBillingAvailable, + (TextSecurePreferences.events.filter { it == TextSecurePreferences.DEBUG_FORCE_NO_BILLING } as Flow<*>) + .onStart { emit(Unit) } + .map { prefs.getDebugForceNoBilling() }, + ){ available, forceNoBilling -> + !forceNoBilling && available + } + .stateIn(scope, SharingStarted.Eagerly, false) override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" @@ -62,18 +85,22 @@ class PlayStoreSubscriptionManager @Inject constructor( .setListener { result, purchases -> Log.d(TAG, "onPurchasesUpdated: $result, $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - for (purchase in purchases) { + purchases.firstOrNull()?.let{ scope.launch { // signal that purchase was completed + try { + //todo PRO send confirmation to libsession + } catch (e : Exception){ + _purchaseEvents.emit(PurchaseEvent.Failed()) + } - //todo PRO send confirmation to libsession _purchaseEvents.emit(PurchaseEvent.Success) } } - } else if (result.responseCode != BillingClient.BillingResponseCode.USER_CANCELED) { + } else { Log.w(TAG, "Purchase failed or cancelled: $result") scope.launch { - _purchaseEvents.emit(PurchaseEvent.Failed()) + _purchaseEvents.emit(PurchaseEvent.Cancelled) } } } @@ -186,11 +213,17 @@ class PlayStoreSubscriptionManager @Inject constructor( billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, "onBillingServiceDisconnected") + + Log.w(TAG, " *** SETTING TO FALSE") + _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { Log.d(TAG, "onBillingSetupFinished with $result") + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + Log.w(TAG, " *** SETTING TO TRUE") + _playBillingAvailable.update { true } + } } }) } From 894cefa1c77705539dfa6cf3bf0ee27221316081 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:11:12 +1100 Subject: [PATCH 078/219] Fixing loading and error diaogs for never-subscribed --- .../prosettings/ProSettingsHomeScreen.kt | 1 - .../prosettings/ProSettingsViewModel.kt | 28 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index d22f35c78c..06419db99e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -131,7 +131,6 @@ fun ProSettingsHome( // add a click handling if the subscription state is loading or errored if(data.subscriptionState.refreshState !is State.Success<*>){ sendCommand(OnHeaderClicked) - //todo PRO double check if KEE is ok to not have two different dialogs for the header vs the action button. If yes then I need to simplify the logic, if not I need to fix the never-subscribed case } else null }, extraHeaderContent = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 8eeca29256..24f0c040ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -288,13 +288,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proAccessLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusRenew)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { @@ -318,6 +323,12 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to @@ -325,7 +336,6 @@ class ProSettingsViewModel @AssistedInject constructor( .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { @@ -489,13 +499,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proStatusLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.checkingProStatusContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } _dialogState.update { it.copy( @@ -518,13 +533,18 @@ class ProSettingsViewModel @AssistedInject constructor( Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() + is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() to + Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() else -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - //todo PRO will need to handle never subscribed here } it.copy( From fa2848201026d302ef312d9015c59e6ecdce777c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:25:06 +1100 Subject: [PATCH 079/219] Adding missing states in the confirmation screen --- .../prosettings/PlanConfirmationScreen.kt | 99 ++++++++++++++++--- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 5e8c6c0b13..13c34c79ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import com.squareup.phrase.Phrase import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants +import org.session.libsession.utilities.NonTranslatableStringConstants.NETWORK_NAME import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToProSettings @@ -110,17 +112,34 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + val description = when (proData.subscriptionState.type) { + is SubscriptionType.Active -> { + Phrase.from(context.getText(R.string.proAllSetDescription)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(DATE_KEY, proData.subscriptionExpiryDate) + .format() + } + + is SubscriptionType.NeverSubscribed -> { + Phrase.from(context.getText(R.string.proUpgraded)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + + is SubscriptionType.Expired -> { + Phrase.from(context.getText(R.string.proPlanRenewSupport)) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .put(NETWORK_NAME_KEY, NETWORK_NAME) + .format() + } + } + Text( modifier = Modifier.align(CenterHorizontally) .safeContentWidth(), - //todo PRO the text below can change if the user was renewing vs expiring and/or/auto-renew - text = annotatedStringResource( - Phrase.from(context.getText(R.string.proAllSetDescription)) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(DATE_KEY, proData.subscriptionExpiryDate) - .format() - ), + text = annotatedStringResource(description), textAlign = TextAlign.Center, style = LocalType.current.base, color = LocalColors.current.text, @@ -128,11 +147,21 @@ fun PlanConfirmation( Spacer(Modifier.height(LocalDimensions.current.spacing)) - //todo PRO the button text can change if the user was renewing vs expiring and/or/auto-renew + val buttonLabel = when (proData.subscriptionState.type) { + is SubscriptionType.Active -> stringResource(R.string.theReturn) + + else -> { + Phrase.from(context.getText(R.string.proStartUsing)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + } + } + AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = stringResource(R.string.theReturn), + text = buttonLabel, onClick = { sendCommand(GoToProSettings) } @@ -146,12 +175,13 @@ fun PlanConfirmation( @Preview @Composable -private fun PreviewPlanConfirmation( +private fun PreviewPlanConfirmationActive( @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors ) { PreviewTheme(colors) { PlanConfirmation( proData = ProSettingsViewModel.ProSettingsState( + subscriptionExpiryDate = "20th June 2026", subscriptionState = SubscriptionState( type = SubscriptionType.Active.AutoRenewing( proStatus = ProStatus.Pro( @@ -176,4 +206,51 @@ private fun PreviewPlanConfirmation( } } +@Preview +@Composable +private fun PreviewPlanConfirmationExpired( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( + device = "iOS", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Success(Unit),), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPlanConfirmationNeverSub( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + PlanConfirmation( + proData = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.NeverSubscribed, + refreshState = State.Success(Unit),), + ), + sendCommand = {}, + onBack = {}, + ) + } +} + + + From 57bf456d87df517314b264984ad7fec5fe78d505 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 15:30:15 +1100 Subject: [PATCH 080/219] Clean up --- .../securesms/preferences/prosettings/ProSettingsHomeScreen.kt | 1 - .../java/org/thoughtcrime/securesms/pro/ProStatusManager.kt | 3 --- 2 files changed, 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index 06419db99e..f5d4751ea1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -168,7 +168,6 @@ fun ProSettingsHome( when(subscriptionType){ is SubscriptionType.Active -> R.string.proErrorRefreshingStatus else -> R.string.errorCheckingProStatus - //todo PRO will need to handle never subscribed here })) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString(), 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 a71d352678..943ef828d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -201,9 +201,6 @@ class ProStatusManager @Inject constructor( } } - //todo PRO add "about to expire" CTA logic on app launch - //todo PRO add "expired" CTA logic on app launch - /** * Logic to determine if we should animate the avatar for a user or freeze it on the first frame */ From 39ed27cf620017825426b6cd1382d088b817677c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:20:41 +1100 Subject: [PATCH 081/219] Proper calculation of the quick refund window --- .../prosettings/ProSettingsViewModel.kt | 26 ++++++++++++++++- .../prosettings/RefundPlanScreen.kt | 29 ++++++++++--------- .../subscription/NoOpSubscriptionManager.kt | 5 +++- .../pro/subscription/SubscriptionManager.kt | 7 ++--- .../PlayStoreSubscriptionManager.kt | 15 +++++++++- 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 24f0c040ae..d72b85f67b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -50,7 +50,6 @@ import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State -import javax.inject.Inject @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -77,6 +76,9 @@ class ProSettingsViewModel @AssistedInject constructor( private val _choosePlanState: MutableStateFlow = MutableStateFlow(ChoosePlanState()) val choosePlanState: StateFlow = _choosePlanState + private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) + val refundPlanState: StateFlow = _refundPlanState + init { // observe subscription status viewModelScope.launch { @@ -223,6 +225,22 @@ class ProSettingsViewModel @AssistedInject constructor( } } } + + // Update refund plan state + viewModelScope.launch { + _proSettingsUIState.map { proUIState -> + val subManager = subscriptionCoordinator.getCurrentManager() + RefundPlanState( + subscriptionType = proUIState.subscriptionState.type, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) + } + .distinctUntilChanged() + .collect { + _refundPlanState.update { it } + } + } } private suspend fun generateState(subscriptionState: SubscriptionState){ @@ -665,6 +683,12 @@ class ProSettingsViewModel @AssistedInject constructor( val enableButton: Boolean = false, ) + data class RefundPlanState( + val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val isQuickRefund: Boolean = false, + val quickRefundUrl: String? = null + ) + data class ProStats( val groupsUpdated: Int = 0, val pinnedConversations: Int = 0, 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 fadc4cda64..e964f36a65 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 @@ -44,21 +44,19 @@ fun RefundPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val planData by viewModel.choosePlanState.collectAsState() - val activePlan = planData.subscriptionType as? SubscriptionType.Active + val refundData by viewModel.refundPlanState.collectAsState() + val activePlan = refundData.subscriptionType as? SubscriptionType.Active if (activePlan == null) { onBack() return } - val subManager = viewModel.getSubscriptionManager() - // there are different UI depending on the state when { // there is an active subscription but from a different platform activePlan.subscriptionDetails.isFromAnotherPlatform() -> RefundPlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, + subscription = activePlan, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -66,7 +64,8 @@ fun RefundPlanScreen( // default refund screen else -> RefundPlan( data = activePlan, - subscriptionManager = subManager, + isQuickRefund = refundData.isQuickRefund, + quickRefundUrl = refundData.quickRefundUrl, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -77,24 +76,24 @@ fun RefundPlanScreen( @Composable fun RefundPlan( data: SubscriptionType.Active, - subscriptionManager: SubscriptionManager, + isQuickRefund: Boolean, + quickRefundUrl: String?, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { val context = LocalContext.current - val isWithinQuickRefundWindow = subscriptionManager.isWithinQuickRefundWindow() BaseCellButtonProSettingsScreen( disabled = true, onBack = onBack, - buttonText = if(isWithinQuickRefundWindow) Phrase.from(context.getText(R.string.openPlatformWebsite)) + buttonText = if(isQuickRefund) Phrase.from(context.getText(R.string.openPlatformWebsite)) .put(PLATFORM_KEY, data.subscriptionDetails.platform) .format().toString() else stringResource(R.string.requestRefund), dangerButton = true, onButtonClick = { - if(isWithinQuickRefundWindow && !subscriptionManager.quickRefundUrl.isNullOrEmpty()){ - sendCommand(ShowOpenUrlDialog(subscriptionManager.quickRefundUrl)) + if(isQuickRefund && !quickRefundUrl.isNullOrEmpty()){ + sendCommand(ShowOpenUrlDialog(quickRefundUrl)) } else { sendCommand(ShowOpenUrlDialog(data.subscriptionDetails.refundUrl)) } @@ -114,7 +113,7 @@ fun RefundPlan( Text( text = annotatedStringResource( - if(isWithinQuickRefundWindow) + if(isQuickRefund) Phrase.from(context.getText(R.string.proRefundRequestStorePolicies)) .put(PLATFORM_KEY, data.subscriptionDetails.platform) .put(APP_NAME_KEY, context.getString(R.string.app_name)) @@ -174,7 +173,8 @@ private fun PreviewRefundPlan( refundUrl = "https://getsession.org/android-refund", ) ), - subscriptionManager = NoOpSubscriptionManager(), + isQuickRefund = false, + quickRefundUrl = "", sendCommand = {}, onBack = {}, ) @@ -203,7 +203,8 @@ private fun PreviewQuickRefundPlan( refundUrl = "https://getsession.org/android-refund", ) ), - subscriptionManager = NoOpSubscriptionManager(), + isQuickRefund = true, + quickRefundUrl = "", sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index d94896898a..1031ad45fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -19,7 +19,6 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val supportsBilling = MutableStateFlow(false) - override val quickRefundExpiry = null override val quickRefundUrl = null override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} @@ -31,4 +30,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override suspend fun hasValidSubscription(productId: String): Boolean { return false } + + override suspend fun isWithinQuickRefundWindow(): Boolean { + return false + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index bd86cc4069..ddcfb41f59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -20,7 +20,6 @@ interface SubscriptionManager: OnAppStartupComponent { val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url - val quickRefundExpiry: Instant? val quickRefundUrl: String? val availablePlans: List @@ -37,11 +36,9 @@ interface SubscriptionManager: OnAppStartupComponent { fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) /** - * Returns true if a provider has a non null [quickRefundExpiry] and the current time is within that window + * Returns true if a provider has a quick refunds and the current time since purchase is within that window */ - fun isWithinQuickRefundWindow(): Boolean { - return quickRefundExpiry != null && Instant.now().isBefore(quickRefundExpiry) - } + suspend fun isWithinQuickRefundWindow(): Boolean /** * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index f593479208..ff920162bd 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant +import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton @@ -70,7 +71,6 @@ class PlayStoreSubscriptionManager @Inject constructor( } .stateIn(scope, SharingStarted.Eagerly, false) - override val quickRefundExpiry: Instant = Instant.now() //todo PRO implement properly override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" private val _purchaseEvents = MutableSharedFlow( @@ -256,6 +256,19 @@ class PlayStoreSubscriptionManager @Inject constructor( else getExistingSubscription() != null } + override suspend fun isWithinQuickRefundWindow(): Boolean { + val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false + + val now = Instant.now() + val purchaseInstant = Instant.ofEpochMilli(purchaseTimeMillis) + + // Google Play allows refunds within 48 hours of purchase + val refundWindowHours = 48 + val refundDeadline = purchaseInstant.plus(refundWindowHours.toLong(), ChronoUnit.HOURS) + + return now.isBefore(refundDeadline) + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } From bffd6cc84949ad9454d9c4f546896f95e294fe48 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:23:11 +1100 Subject: [PATCH 082/219] Adding back temp suffix removal --- app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index da96b2db71..d769563e80 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -203,10 +203,11 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix("") + setAuthorityPostfix(".debug") } } From c161e1276cde5aa5212c44ef6b60c9a254183c84 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 16:28:06 +1100 Subject: [PATCH 083/219] Clean up --- .../securesms/preferences/prosettings/ProSettingsViewModel.kt | 2 -- .../securesms/pro/subscription/PlayStoreSubscriptionManager.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index d72b85f67b..c8111020a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -249,8 +249,6 @@ class ProSettingsViewModel @AssistedInject constructor( val subType = subscriptionState.type _proSettingsUIState.update { - Log.w("", " *** SETTING VM TO: ${subscriptionCoordinator.getCurrentManager().supportsBilling.value}") - ProSettingsState( subscriptionState = subscriptionState, //todo PRO need to get the product id from libsession - also this might be a long running operation diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index ff920162bd..af3f3091d6 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -214,14 +214,12 @@ class PlayStoreSubscriptionManager @Inject constructor( billingClient.startConnection(object : BillingClientStateListener { override fun onBillingServiceDisconnected() { - Log.w(TAG, " *** SETTING TO FALSE") _playBillingAvailable.update { false } } override fun onBillingSetupFinished(result: BillingResult) { Log.d(TAG, "onBillingSetupFinished with $result") if (result.responseCode == BillingClient.BillingResponseCode.OK) { - Log.w(TAG, " *** SETTING TO TRUE") _playBillingAvailable.update { true } } } From f95ef222d7456279eec035c434fa36ae8559fa21 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 27 Oct 2025 18:16:47 +1100 Subject: [PATCH 084/219] Update app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt Co-authored-by: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> --- .../securesms/preferences/prosettings/BaseProSettingsScreens.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 661dc14976..126419b867 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -90,7 +90,7 @@ fun BaseProSettingsScreen( val lazyListState = rememberLazyListState() // Calculate scroll fraction val density = LocalDensity.current - val thresholdPx = remember { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque + val thresholdPx = remember(density) { with(density) { 28.dp.toPx() } } // amount before the appbar gets fully opaque // raw fraction 0..1 derived from scrolling val rawFraction by remember { From 4095fafee29a3e8f79fb63f42d5131ca63b2f0c7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 09:13:45 +1100 Subject: [PATCH 085/219] Change the loading style to a nicer UI --- .../prosettings/BaseProSettingsScreens.kt | 88 ++++--------------- .../chooseplan/ChoosePlanScreen.kt | 27 ++++-- .../PlayStoreSubscriptionManager.kt | 1 - 3 files changed, 37 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 661dc14976..719141cbce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -33,33 +33,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.consumeAllChanges -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.progressBarRangeInfo -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import network.loki.messenger.R -import org.thoughtcrime.securesms.ui.AnimateFade import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogBg import org.thoughtcrime.securesms.ui.SessionProSettingsHeader import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.DangerFillButtonRect import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.components.inlineContentMap @@ -69,7 +57,6 @@ import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors -import org.thoughtcrime.securesms.ui.theme.blackAlpha40 import org.thoughtcrime.securesms.ui.theme.bold /** @@ -79,7 +66,6 @@ import org.thoughtcrime.securesms.ui.theme.bold @Composable fun BaseProSettingsScreen( disabled: Boolean, - loading: Boolean = false, hideHomeAppBar: Boolean = false, onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, @@ -121,64 +107,28 @@ fun BaseProSettingsScreen( }} else {{}}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), ) { paddings -> - - Box( + LazyColumn( modifier = Modifier .fillMaxSize() + .consumeWindowInsets(paddings) + .padding(horizontal = LocalDimensions.current.spacing), + state = lazyListState, + contentPadding = PaddingValues( + top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) + .coerceAtLeast(0.dp) + 46.dp, + bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing + ), + horizontalAlignment = CenterHorizontally ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .consumeWindowInsets(paddings) - .padding(horizontal = LocalDimensions.current.spacing), - state = lazyListState, - contentPadding = PaddingValues( - top = (paddings.calculateTopPadding() - LocalDimensions.current.appBarHeight) - .coerceAtLeast(0.dp) + 46.dp, - bottom = paddings.calculateBottomPadding() + LocalDimensions.current.spacing - ), - horizontalAlignment = CenterHorizontally - ) { - item { - SessionProSettingsHeader( - disabled = disabled, - onClick = onHeaderClick, - extraContent = extraHeaderContent - ) - } - - item { content() } + item { + SessionProSettingsHeader( + disabled = disabled, + onClick = onHeaderClick, + extraContent = extraHeaderContent + ) } - } - AnimateFade(loading) { - val loadingLabel = stringResource(R.string.loading) - Box( - modifier = Modifier - .fillMaxSize() - // dim the background so it's visually obvious it's blocked (optional) - .background(blackAlpha40) - // Intercept click events so that when the loading is on we can't click beneath - .pointerInput(loading) { - if (loading) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent() - event.changes.forEach { it.consume() } - } - } - } - } - // Provide proper a11y semantics: announce an indeterminate progress. - .semantics(mergeDescendants = true) { - progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate - contentDescription = loadingLabel - liveRegion = LiveRegionMode.Polite - }, - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } + item { content() } } } } @@ -195,12 +145,10 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, - loading: Boolean = false, content: @Composable () -> Unit ) { BaseProSettingsScreen( disabled = disabled, - loading = loading, onBack = onBack, ) { Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -286,14 +234,12 @@ fun BaseNonOriginatingProSettingsScreen( headerTitle: CharSequence?, contentTitle: String?, contentDescription: CharSequence?, - loading: Boolean = false, contentClick: (() -> Unit)? = null, linkCellsInfo: String?, linkCells: List = emptyList(), ) { BaseCellButtonProSettingsScreen( disabled = disabled, - loading = loading, onBack = onBack, buttonText = buttonText, dangerButton = dangerButton, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 8e3140e21a..f81430fc97 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -65,6 +66,7 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.P import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.pro.subscription.expiryFromNow +import org.thoughtcrime.securesms.ui.LoadingArcOr import org.thoughtcrime.securesms.ui.SpeechBubbleTooltip import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.RadioButtonIndicator @@ -92,7 +94,6 @@ fun ChoosePlan( ) { BaseProSettingsScreen( disabled = false, - loading = planData.loading, onBack = onBack, ) { // Keeps track of the badge height dynamically so we can adjust the padding accordingly @@ -162,6 +163,7 @@ fun ChoosePlan( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, + enabled = !planData.loading, onClick = { sendCommand(SelectProPlan(data)) } @@ -184,12 +186,15 @@ fun ChoosePlan( AccentFillButtonRect( modifier = Modifier.fillMaxWidth() .widthIn(max = LocalDimensions.current.maxContentWidth), - text = buttonLabel, enabled = planData.enableButton, onClick = { sendCommand(GetProPlan) } - ) + ){ + LoadingArcOr(loading = planData.loading) { + Text(text = buttonLabel) + } + } Spacer(Modifier.height(LocalDimensions.current.xxsSpacing)) @@ -240,6 +245,7 @@ fun ChoosePlan( private fun PlanItem( proPlan: ProPlan, badgePadding: Dp, + enabled: Boolean, modifier: Modifier= Modifier, onBadgeLaidOut: (Dp) -> Unit, onClick: () -> Unit @@ -262,9 +268,13 @@ private fun PlanItem( shape = MaterialTheme.shapes.small ) .clip(MaterialTheme.shapes.small) - .clickable( - onClick = onClick - ) + .then( + if (enabled) Modifier.clickable( + onClick = onClick + ) + else Modifier + ), + ) { Row( modifier = Modifier.fillMaxWidth() @@ -289,7 +299,7 @@ private fun PlanItem( RadioButtonIndicator( selected = proPlan.selected, - enabled = true, + enabled = enabled, colors = radioButtonColors( unselectedBorder = LocalColors.current.borders, selectedBorder = LocalColors.current.accent, @@ -399,6 +409,7 @@ private fun PreviewUpdatePlanItems( ), badgePadding = 0.dp, onBadgeLaidOut = {}, + enabled = true, onClick = {} ) @@ -415,6 +426,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) @@ -435,6 +447,7 @@ private fun PreviewUpdatePlanItems( ), ), badgePadding = 0.dp, + enabled = true, onBadgeLaidOut = {}, onClick = {} ) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index af3f3091d6..404f92b389 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow From ec660d10998f243cd0fc0ab43a368285655b0cb4 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 10:45:30 +1100 Subject: [PATCH 086/219] Making sure we do not show the loader if the purchase fails right away --- .../securesms/debugmenu/DebugMenuViewModel.kt | 4 +- .../prosettings/ProSettingsViewModel.kt | 16 +- .../subscription/NoOpSubscriptionManager.kt | 4 +- .../pro/subscription/SubscriptionManager.kt | 2 +- .../PlayStoreSubscriptionManager.kt | 138 +++++++++--------- 5 files changed, 89 insertions(+), 75 deletions(-) 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 8688a484ba..03d1e572c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -326,7 +326,9 @@ class DebugMenuViewModel @Inject constructor( } is Commands.PurchaseDebugPlan -> { - command.plan.apply { manager.purchasePlan(plan) } + viewModelScope.launch { + command.plan.apply { manager.purchasePlan(plan) } + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index c8111020a4..e74b08c7d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -619,13 +619,17 @@ class ProSettingsViewModel @AssistedInject constructor( } private fun getPlanFromProvider(){ - _choosePlanState.update { - it.copy(loading = true) - } + viewModelScope.launch { + val purchaseStarted = subscriptionCoordinator.getCurrentManager().purchasePlan( + getSelectedPlan().durationType + ) - subscriptionCoordinator.getCurrentManager().purchasePlan( - getSelectedPlan().durationType - ) + if(purchaseStarted.isSuccess) { + _choosePlanState.update { + it.copy(loading = true) + } + } + } } fun getSubscriptionManager(): SubscriptionManager { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 1031ad45fb..967056de8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -21,7 +21,9 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val quickRefundUrl = null - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) {} + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + return Result.success(Unit) + } override val availablePlans: List get() = emptyList() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index ddcfb41f59..3a516c5012 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -33,7 +33,7 @@ interface SubscriptionManager: OnAppStartupComponent { // purchase events val purchaseEvents: SharedFlow - fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) + suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result /** * Returns true if a provider has a quick refunds and the current time since purchase is within that window diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 404f92b389..511066a1d6 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application import android.widget.Toast +import androidx.compose.ui.res.stringResource import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams @@ -16,6 +17,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope @@ -89,6 +92,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // signal that purchase was completed try { //todo PRO send confirmation to libsession + delay(4000) } catch (e : Exception){ _purchaseEvents.emit(PurchaseEvent.Failed()) } @@ -116,87 +120,89 @@ class PlayStoreSubscriptionManager @Inject constructor( override val availablePlans: List = ProSubscriptionDuration.entries.toList() - override fun purchasePlan(subscriptionDuration: ProSubscriptionDuration) { - scope.launch { - try { - val activity = checkNotNull(currentActivityObserver.currentActivity.value) { - "No current activity available to launch the billing flow" - } + override suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result { + try { + val activity = checkNotNull(currentActivityObserver.currentActivity.value) { + "No current activity available to launch the billing flow" + } - val result = billingClient.queryProductDetails( - QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("session_pro") - .setProductType(BillingClient.ProductType.SUBS) - .build() - ) + val result = billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() ) - .build() - ) + ) + .build() + ) - check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Failed to query product details. Reason: ${result.billingResult}" - } + check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result.billingResult}" + } - val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { - "Unable to get the product: product for given id is null" - } + val productDetails = checkNotNull(result.productDetailsList?.firstOrNull()) { + "Unable to get the product: product for given id is null" + } - val planId = subscriptionDuration.planId + val planId = subscriptionDuration.planId - val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails - ?.firstOrNull { it.basePlanId == planId }) { - "Unable to find a plan with id $planId" - } + val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails + ?.firstOrNull { it.basePlanId == planId }) { + "Unable to find a plan with id $planId" + } - // Check for existing subscription - val existingPurchase = getExistingSubscription() + // Check for existing subscription + val existingPurchase = getExistingSubscription() - val billingFlowParamsBuilder = BillingFlowParams.newBuilder() - .setProductDetailsParamsList( - listOf( - BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerDetails.offerToken) - .build() - ) - ) - - // If user has an existing subscription, configure upgrade/downgrade - if (existingPurchase != null) { - Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") - - billingFlowParamsBuilder.setSubscriptionUpdateParams( - BillingFlowParams.SubscriptionUpdateParams.newBuilder() - .setOldPurchaseToken(existingPurchase.purchaseToken) - // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews - // This applies whether the subscription is auto-renewing or canceled - .setSubscriptionReplacementMode( - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION - ) + val billingFlowParamsBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList( + listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerDetails.offerToken) .build() ) - } + ) - val billingResult = billingClient.launchBillingFlow( - activity, - billingFlowParamsBuilder.build() + // If user has an existing subscription, configure upgrade/downgrade + if (existingPurchase != null) { + Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + + billingFlowParamsBuilder.setSubscriptionUpdateParams( + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(existingPurchase.purchaseToken) + // WITHOUT_PRORATION ensures new plan only bills when existing plan expires/renews + // This applies whether the subscription is auto-renewing or canceled + .setSubscriptionReplacementMode( + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION + ) + .build() ) + } - check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Error purchase plan", e) + val billingResult = billingClient.launchBillingFlow( + activity, + billingFlowParamsBuilder.build() + ) - withContext(Dispatchers.Main) { - Toast.makeText(application, e.message, Toast.LENGTH_LONG).show() - } + check(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Unable to launch the billing flow. Reason: ${billingResult.debugMessage}" + } + + return Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error purchase plan", e) + + withContext(Dispatchers.Main) { + Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() } + + return Result.failure(e) } } From 32b457fa5a35729218e185ce504c9a552ff1a72a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 14:18:02 +1100 Subject: [PATCH 087/219] UI Update to match designs --- .../prosettings/BaseProSettingsScreens.kt | 10 +- .../prosettings/ProSettingsActivity.kt | 2 +- .../prosettings/ProSettingsHomeScreen.kt | 237 ++++++++++++------ .../prosettings/ProSettingsNavHost.kt | 181 ++++++------- .../chooseplan/ChoosePlanScreen.kt | 8 +- .../securesms/ui/ProComponents.kt | 14 +- 6 files changed, 264 insertions(+), 188 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt index 93fd2fcf08..8bba3a5f18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseProSettingsScreens.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.only @@ -22,7 +21,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -36,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign @@ -70,7 +72,7 @@ fun BaseProSettingsScreen( onBack: () -> Unit, onHeaderClick: (() -> Unit)? = null, extraHeaderContent: @Composable (() -> Unit)? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ){ // We need the app bar to start as transparent and slowly go opaque as we scroll val lazyListState = rememberLazyListState() @@ -109,7 +111,7 @@ fun BaseProSettingsScreen( ) { paddings -> LazyColumn( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .consumeWindowInsets(paddings) .padding(horizontal = LocalDimensions.current.spacing), state = lazyListState, @@ -145,7 +147,7 @@ fun BaseCellButtonProSettingsScreen( dangerButton: Boolean, onButtonClick: () -> Unit, title: CharSequence? = null, - content: @Composable () -> Unit + content: @Composable LazyItemScope.() -> Unit ) { BaseProSettingsScreen( disabled = disabled, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt index 710d638c2f..1d6fdff0cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsActivity.kt @@ -32,7 +32,7 @@ class ProSettingsActivity: FullComposeScreenLockActivity() { ) ?: ProSettingsDestination.Home ProSettingsNavHost( - hideHomeAppBar = false, + inSheet = false, startDestination = startDestination, onBack = this::finish ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index f5d4751ea1..e349593e3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -76,7 +76,6 @@ import org.thoughtcrime.securesms.ui.components.inlineContentMap import org.thoughtcrime.securesms.ui.proBadgeColorDisabled import org.thoughtcrime.securesms.ui.proBadgeColorStandard import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.shimmerOverlay import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -99,14 +98,14 @@ import java.time.Instant @Composable fun ProSettingsHomeScreen( viewModel: ProSettingsViewModel, - hideHomeAppBar: Boolean, + inSheet: Boolean, onBack: () -> Unit, ) { val data by viewModel.proSettingsUIState.collectAsState() ProSettingsHome( data = data, - hideHomeAppBar = hideHomeAppBar, + inSheet = inSheet, sendCommand = viewModel::onCommand, onBack = onBack, ) @@ -116,16 +115,19 @@ fun ProSettingsHomeScreen( @Composable fun ProSettingsHome( data: ProSettingsViewModel.ProSettingsState, - hideHomeAppBar: Boolean, + inSheet: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { val subscriptionType = data.subscriptionState.type val context = LocalContext.current + val expiredInMainScreen = subscriptionType is SubscriptionType.Expired && !inSheet + val expiredInSheet = subscriptionType is SubscriptionType.Expired && inSheet + BaseProSettingsScreen( - disabled = subscriptionType is SubscriptionType.Expired, - hideHomeAppBar = hideHomeAppBar, + disabled = expiredInMainScreen, + hideHomeAppBar = inSheet, onBack = onBack, onHeaderClick = { // add a click handling if the subscription state is loading or errored @@ -189,14 +191,18 @@ fun ProSettingsHome( } } ) { - // Header for non-pro users - if(subscriptionType is SubscriptionType.NeverSubscribed) { + // Header for non-pro users or expired users in sheet mode + if(subscriptionType is SubscriptionType.NeverSubscribed || expiredInSheet) { if(data.subscriptionState.refreshState !is State.Success){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + Spacer(Modifier.height(LocalDimensions.current.contentSpacing)) } Text( - text = Phrase.from(context.getText(R.string.proFullestPotential)) + text = if(expiredInSheet) Phrase.from(context.getText(R.string.proAccessRenewStart)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else Phrase.from(context.getText(R.string.proFullestPotential)) .put(APP_NAME_KEY, stringResource(R.string.app_name)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format().toString(), @@ -213,7 +219,7 @@ fun ProSettingsHome( onClick = { sendCommand(GoToChoosePlan) } ) } - + // Pro Stats if(subscriptionType is SubscriptionType.Active){ Spacer(Modifier.height(LocalDimensions.current.spacing)) @@ -235,7 +241,7 @@ fun ProSettingsHome( } // Manage Pro - Expired - if(subscriptionType is SubscriptionType.Expired){ + if(expiredInMainScreen){ Spacer(Modifier.height(LocalDimensions.current.spacing)) ProManage( data = subscriptionType, @@ -248,67 +254,17 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) ProFeatures( data = subscriptionType, + disabled = expiredInMainScreen, sendCommand = sendCommand, ) - // Manage Pro - Pro - if(subscriptionType is SubscriptionType.Active){ - Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) - ProManage( - data = subscriptionType, - subscriptionRefreshState = data.subscriptionState.refreshState, - sendCommand = sendCommand, - ) - } - - // Help - Spacer(Modifier.height(LocalDimensions.current.spacing)) - CategoryCell( - title = stringResource(R.string.sessionHelp), - ) { - val iconColor = if(subscriptionType is SubscriptionType.Expired) LocalColors.current.text - else LocalColors.current.accentText - - // Cell content - Column( - modifier = Modifier.fillMaxWidth(), - ) { - IconActionRowItem( - title = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaq) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proFaqDescription) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_faq, - onClick = { - sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) - } - ) - Divider() - IconActionRowItem( - title = annotatedStringResource(R.string.helpSupport), - subtitle = annotatedStringResource( - Phrase.from(LocalContext.current, R.string.proSupportDescription) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .format().toString() - ), - icon = R.drawable.ic_square_arrow_up_right, - iconSize = LocalDimensions.current.iconMedium, - iconColor = iconColor, - qaTag = R.string.qa_pro_settings_action_support, - onClick = { - sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) - } - ) - } + // do not display the footer in sheet mode + if(!inSheet){ + ProSettingsFooter( + subscriptionType = subscriptionType, + subscriptionRefreshState = data.subscriptionState.refreshState, + sendCommand = sendCommand + ) } Spacer(modifier = Modifier.height(LocalDimensions.current.spacing)) @@ -611,6 +567,7 @@ fun ProSettings( fun ProFeatures( modifier: Modifier = Modifier, data: SubscriptionType, + disabled: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { CategoryCell( @@ -643,7 +600,7 @@ fun ProFeatures( icon = R.drawable.ic_message_square, iconGradientStart = primaryBlue, iconGradientEnd = primaryPurple, - expired = data is SubscriptionType.Expired + expired = disabled ) // Unlimited pins @@ -653,7 +610,7 @@ fun ProFeatures( icon = R.drawable.ic_pin, iconGradientStart = primaryPurple, iconGradientEnd = primaryPink, - expired = data is SubscriptionType.Expired + expired = disabled ) // Animated pics @@ -663,7 +620,7 @@ fun ProFeatures( icon = R.drawable.ic_square_play, iconGradientStart = primaryPink, iconGradientEnd = primaryRed, - expired = data is SubscriptionType.Expired + expired = disabled ) // Pro badges @@ -677,7 +634,7 @@ fun ProFeatures( icon = R.drawable.ic_rectangle_ellipsis, iconGradientStart = primaryRed, iconGradientEnd = primaryOrange, - expired = data is SubscriptionType.Expired, + expired = disabled, showProBadge = true, ) @@ -694,7 +651,7 @@ fun ProFeatures( icon = R.drawable.ic_circle_plus, iconGradientStart = primaryOrange, iconGradientEnd = primaryYellow, - expired = data is SubscriptionType.Expired, + expired = disabled, onClick = { sendCommand(ShowOpenUrlDialog("https://getsession.org/pro-roadmap")) } @@ -899,6 +856,73 @@ fun ProManage( } } +@Composable +fun ProSettingsFooter( + subscriptionType: SubscriptionType, + subscriptionRefreshState: State, + sendCommand: (ProSettingsViewModel.Commands) -> Unit, +) { + // Manage Pro - Pro + if(subscriptionType is SubscriptionType.Active){ + Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) + ProManage( + data = subscriptionType, + subscriptionRefreshState = subscriptionRefreshState, + sendCommand = sendCommand, + ) + } + + // Help + Spacer(Modifier.height(LocalDimensions.current.spacing)) + CategoryCell( + title = stringResource(R.string.sessionHelp), + ) { + val iconColor = if(subscriptionType is SubscriptionType.Expired) LocalColors.current.text + else LocalColors.current.accentText + + // Cell content + Column( + modifier = Modifier.fillMaxWidth(), + ) { + IconActionRowItem( + title = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaq) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proFaqDescription) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_faq, + onClick = { + sendCommand(ShowOpenUrlDialog("https://getsession.org/faq#pro")) + } + ) + Divider() + IconActionRowItem( + title = annotatedStringResource(R.string.helpSupport), + subtitle = annotatedStringResource( + Phrase.from(LocalContext.current, R.string.proSupportDescription) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format().toString() + ), + icon = R.drawable.ic_square_arrow_up_right, + iconSize = LocalDimensions.current.iconMedium, + iconColor = iconColor, + qaTag = R.string.qa_pro_settings_action_support, + onClick = { + sendCommand(ShowOpenUrlDialog(ProStatusManager.URL_PRO_SUPPORT)) + } + ) + } + } +} + @Preview @Composable fun PreviewProSettingsPro( @@ -926,7 +950,7 @@ fun PreviewProSettingsPro( refreshState = State.Success(Unit), ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -960,7 +984,7 @@ fun PreviewProSettingsProLoading( refreshState = State.Loading, ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -994,7 +1018,7 @@ fun PreviewProSettingsProError( refreshState = State.Error(Exception()), ), ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1023,7 +1047,36 @@ fun PreviewProSettingsExpired( refreshState = State.Success(Unit), ) ), - hideHomeAppBar = false, + inSheet = false, + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsExpiredInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.Expired( + expiredAt = Instant.now() - Duration.ofDays(14), + SubscriptionDetails( + device = "iOS", + store = "Apple App Store", + platform = "Apple", + platformAccount = "Apple Account", + subscriptionUrl = "https://www.apple.com/account/subscriptions", + refundUrl = "https://www.apple.com/account/subscriptions", + )), + refreshState = State.Success(Unit), + ) + ), + inSheet = true, sendCommand = {}, onBack = {}, ) @@ -1052,7 +1105,7 @@ fun PreviewProSettingsExpiredLoading( refreshState = State.Loading, ) ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1081,7 +1134,7 @@ fun PreviewProSettingsExpiredError( refreshState = State.Error(Exception()), ) ), - hideHomeAppBar = false, + inSheet = false, sendCommand = {}, onBack = {}, ) @@ -1101,7 +1154,27 @@ fun PreviewProSettingsNonPro( refreshState = State.Success(Unit), ) ), - hideHomeAppBar = false, + inSheet = false, + sendCommand = {}, + onBack = {}, + ) + } +} + +@Preview +@Composable +fun PreviewProSettingsNonProInSheet( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + ProSettingsHome( + data = ProSettingsViewModel.ProSettingsState( + subscriptionState = SubscriptionState( + type = SubscriptionType.NeverSubscribed, + refreshState = State.Success(Unit), + ) + ), + inSheet = true, sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 3123ee4292..b96c374ec4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -4,10 +4,14 @@ import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -62,110 +66,111 @@ sealed interface ProSettingsDestination: Parcelable { @Composable fun ProSettingsNavHost( startDestination: ProSettingsDestination = Home, - hideHomeAppBar: Boolean, + inSheet: Boolean, onBack: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - val navigator: UINavigator = remember { - UINavigator() + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root } + } - val handleBack: () -> Unit = { - if (navController.previousBackStackEntry != null) { - navController.navigateUp() - } else { - onBack() // Finish activity if at root + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) } - } + NavigationAction.NavigateUp -> handleBack() - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } - NavigationAction.NavigateUp -> handleBack() + is NavigationAction.ReturnResult -> {} + } + } - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + NavHost( + navController = navController, + startDestination = ProSettingsGraph + ) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + ProSettingsHomeScreen( + viewModel = viewModel, + inSheet = inSheet, + onBack = onBack, + ) + } - is NavigationAction.ReturnResult -> {} + // Subscription plan selection + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + UpdatePlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + GetOrRenewPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) } - } - NavHost(navController = navController, startDestination = ProSettingsGraph) { - navigation(startDestination = startDestination) { - // Home - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - ProSettingsHomeScreen( - viewModel = viewModel, - hideHomeAppBar = hideHomeAppBar, - onBack = onBack, - ) - } - - // Subscription plan selection - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - UpdatePlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - GetOrRenewPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Subscription plan confirmation - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - PlanConfirmationScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Refund - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - RefundPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - - // Cancellation - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - CancelPlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } + // Subscription plan confirmation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + PlanConfirmationScreen( + viewModel = viewModel, + onBack = handleBack, + ) } - } - // Dialogs - // the composable need to wait until the graph has been rendered - val graphReady = remember(navController.currentBackStackEntryAsState().value) { - runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() - } - graphReady?.let { entry -> - val vm = navController.proGraphViewModel(entry, navigator) - val dialogsState by vm.dialogState.collectAsState() - ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) + // Refund + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + RefundPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } + + // Cancellation + horizontalSlideComposable { entry -> + val viewModel = navController.proGraphViewModel(entry, navigator) + CancelPlanScreen( + viewModel = viewModel, + onBack = handleBack, + ) + } } } + + // Dialogs + // the composable need to wait until the graph has been rendered + val graphReady = remember(navController.currentBackStackEntryAsState().value) { + runCatching { navController.getBackStackEntry(ProSettingsGraph) }.getOrNull() + } + graphReady?.let { entry -> + val vm = navController.proGraphViewModel(entry, navigator) + val dialogsState by vm.dialogState.collectAsState() + ProSettingsDialogs(dialogsState = dialogsState, sendCommand = vm::onCommand) + } } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index f81430fc97..6041b01578 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -105,12 +105,6 @@ fun ChoosePlan( val context = LocalContext.current val title = when (planData.subscriptionType) { - is SubscriptionType.Expired -> - Phrase.from(context.getText(R.string.proAccessRenewStart)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - is SubscriptionType.Active.Expiring -> Phrase.from(context.getText(R.string.proAccessActivatedNotAuto)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) @@ -128,7 +122,7 @@ fun ChoosePlan( .put(DATE_KEY, planData.subscriptionType.duration.expiryFromNow()) .format() - is SubscriptionType.NeverSubscribed -> + else -> Phrase.from(context.getText(R.string.proChooseAccess)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 7dfd639686..80acdb84d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -25,9 +25,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -88,7 +88,6 @@ import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet -import org.thoughtcrime.securesms.ui.components.FillButtonRect import org.thoughtcrime.securesms.ui.components.QrImage import org.thoughtcrime.securesms.ui.components.TertiaryFillButtonRect import org.thoughtcrime.securesms.ui.theme.LocalColors @@ -362,15 +361,18 @@ fun SessionProCTA( ) { BoxWithConstraints(modifier = modifier) { val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - val targetHeight = - (this.maxHeight - topInset) * 0.94f // sheet should take up 94% of the height, without the status bar + val maxHeight = + (this.maxHeight - topInset) * 0.8f // sheet should take up 80% of the height, without the status bar + Box( - modifier = Modifier.height(targetHeight), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight), contentAlignment = Alignment.TopCenter ) { ProSettingsNavHost( startDestination = ProSettingsDestination.Home, - hideHomeAppBar = true, + inSheet = true, onBack = dismissSheet ) From 58e6f0c1536c70cf2e6c04a565d9f8e978785537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:50:02 +0000 Subject: [PATCH 088/219] Bump com.google.devtools.ksp from 2.2.20-2.0.4 to 2.3.0 (#1644) --- 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 49a4f171e5..45897122e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ googleServicesVersion = "4.4.3" junit = "1.3.0" kotlinVersion = "2.2.20" kryoVersion = "5.6.2" -kspVersion = "2.2.20-2.0.4" +kspVersion = "2.3.0" legacySupportV13Version = "1.0.0" libsessionUtilAndroidVersion = "1.0.8-1-g27817b4" media3ExoplayerVersion = "1.8.0" From 13e3453067fab32318bac97ebb9968cb521c2abd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:00:46 +0000 Subject: [PATCH 089/219] Bump com.google.gms.google-services from 4.4.3 to 4.4.4 (#1620) --- 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 45897122e4..ecb0e2aa4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ flexboxVersion = "3.0.0" fragmentKtxVersion = "1.8.9" gradlePluginVersion = "8.13.0" dependenciesAnalysisVersion = "3.1.0" -googleServicesVersion = "4.4.3" +googleServicesVersion = "4.4.4" junit = "1.3.0" kotlinVersion = "2.2.20" kryoVersion = "5.6.2" From adeddde8d8eb80e9ec5b967905670f71aaee6d6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 04:11:36 +0000 Subject: [PATCH 090/219] Bump cameraCamera2Version from 1.5.0 to 1.5.1 (#1619) --- 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 ecb0e2aa4d..20f1506810 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ androidImageCropperVersion = "4.6.0" androidVersion = "137.7151.04" assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" -cameraCamera2Version = "1.5.0" +cameraCamera2Version = "1.5.1" cardviewVersion = "1.0.0" composeBomVersion = "2025.10.01" conscryptAndroidVersion = "2.5.3" From e7a2c1b03c229b88f25215355908d644cc2d330d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Tue, 28 Oct 2025 16:05:32 +1100 Subject: [PATCH 091/219] More state and UI handling to match designs --- .../prosettings/ProSettingsHomeScreen.kt | 59 +++++++++++++++---- .../prosettings/ProSettingsViewModel.kt | 40 ++++++++----- .../securesms/ui/ProComponents.kt | 2 +- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt index e349593e3b..32d837db16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsHomeScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -54,11 +56,17 @@ import org.session.libsession.utilities.StringSubstitutionConstants.ICON_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus import org.session.libsession.utilities.recipients.shouldShowProBadge -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.* +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToCancel +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToChoosePlan +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToRefund +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OnHeaderClicked +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OnProStatsClicked +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.SetShowProBadge +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionDetails -import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.SubscriptionState +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration import org.thoughtcrime.securesms.ui.ActionRowItem import org.thoughtcrime.securesms.ui.CategoryCell @@ -132,7 +140,7 @@ fun ProSettingsHome( onHeaderClick = { // add a click handling if the subscription state is loading or errored if(data.subscriptionState.refreshState !is State.Success<*>){ - sendCommand(OnHeaderClicked) + sendCommand(OnHeaderClicked(inSheet)) } else null }, extraHeaderContent = { @@ -212,12 +220,34 @@ fun ProSettingsHome( Spacer(Modifier.height(LocalDimensions.current.spacing)) - AccentFillButtonRect( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.theContinue), - enabled = data.subscriptionState.refreshState is State.Success, - onClick = { sendCommand(GoToChoosePlan) } - ) + Box { + val enableButon = data.subscriptionState.refreshState is State.Success + AccentFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.theContinue), + enabled = enableButon, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + // the designs require we should still be able to click on the disabled button... + // this goes against the system the built in ux decisions. + // To avoid extending the button we will instead add a clickable area above the button, + // invisible to screen readers as this is purely a visual action in case people try to + // click in spite of the state being "loading" or "error" + if (!enableButon) { + Box( + modifier = Modifier.fillMaxWidth() + .height(LocalDimensions.current.minItemButtonHeight) + .semantics { + hideFromAccessibility() + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { sendCommand(GoToChoosePlan(inSheet)) } + ) + ) { } + } + } } // Pro Stats @@ -235,6 +265,7 @@ fun ProSettingsHome( ProSettings( data = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, expiry = data.subscriptionExpiryLabel, sendCommand = sendCommand, ) @@ -246,6 +277,7 @@ fun ProSettingsHome( ProManage( data = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, sendCommand = sendCommand, ) } @@ -263,6 +295,7 @@ fun ProSettingsHome( ProSettingsFooter( subscriptionType = subscriptionType, subscriptionRefreshState = data.subscriptionState.refreshState, + inSheet = inSheet, sendCommand = sendCommand ) } @@ -478,6 +511,7 @@ fun ProSettings( modifier: Modifier = Modifier, data: SubscriptionType.Active, subscriptionRefreshState: State, + inSheet: Boolean, expiry: CharSequence, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ @@ -540,7 +574,7 @@ fun ProSettings( } }, qaTag = R.string.qa_pro_settings_action_update_plan, - onClick = { sendCommand(GoToChoosePlan) } + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -730,6 +764,7 @@ private fun ProFeatureItem( fun ProManage( modifier: Modifier = Modifier, data: SubscriptionType, + inSheet: Boolean, subscriptionRefreshState: State, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ){ @@ -832,7 +867,7 @@ fun ProManage( } }, qaTag = R.string.qa_pro_settings_action_renew_plan, - onClick = { sendCommand(GoToChoosePlan) } + onClick = { sendCommand(GoToChoosePlan(inSheet)) } ) Divider() @@ -860,6 +895,7 @@ fun ProManage( fun ProSettingsFooter( subscriptionType: SubscriptionType, subscriptionRefreshState: State, + inSheet: Boolean, sendCommand: (ProSettingsViewModel.Commands) -> Unit, ) { // Manage Pro - Pro @@ -867,6 +903,7 @@ fun ProSettingsFooter( Spacer(Modifier.height(LocalDimensions.current.smallSpacing)) ProManage( data = subscriptionType, + inSheet = inSheet, subscriptionRefreshState = subscriptionRefreshState, sendCommand = sendCommand, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index e74b08c7d2..26f4814934 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -293,18 +293,20 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.GoToChoosePlan -> { + is Commands.GoToChoosePlan -> { when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessLoading)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proAccessLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + state is SubscriptionType.NeverSubscribed + || command.inSheet -> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusContinue)) @@ -331,15 +333,17 @@ class ProSettingsViewModel @AssistedInject constructor( } is State.Error -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessError)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proAccessError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proAccessNetworkLoadError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + state is SubscriptionType.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) @@ -504,18 +508,20 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.OnHeaderClicked -> { + is Commands.OnHeaderClicked -> { when(_proSettingsUIState.value.subscriptionState.refreshState){ // if we are in a loading or refresh state we should show a dialog instead is State.Loading -> { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusLoading)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusLoadingDescription)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.checkingProStatus)) + state is SubscriptionType.NeverSubscribed + || command.inSheet-> Phrase.from(context.getText(R.string.checkingProStatus)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.checkingProStatusContinue)) @@ -542,14 +548,16 @@ class ProSettingsViewModel @AssistedInject constructor( is State.Error -> { _dialogState.update { - val (title, message) = when(_proSettingsUIState.value.subscriptionState.type){ - is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusError)) + val state = _proSettingsUIState.value.subscriptionState.type + val (title, message) = when{ + state is SubscriptionType.Active -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusRefreshNetworkError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() - is SubscriptionType.NeverSubscribed -> Phrase.from(context.getText(R.string.proStatusError)) + state is SubscriptionType.NeverSubscribed || + command.inSheet -> Phrase.from(context.getText(R.string.proStatusError)) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format().toString() to Phrase.from(context.getText(R.string.proStatusNetworkErrorContinue)) @@ -651,7 +659,7 @@ class ProSettingsViewModel @AssistedInject constructor( data object HideTCPolicyDialog: Commands data object HideSimpleDialog : Commands - object GoToChoosePlan: Commands + data class GoToChoosePlan(val inSheet: Boolean): Commands object GoToRefund: Commands object GoToCancel: Commands object GoToProSettings: Commands @@ -664,7 +672,7 @@ class ProSettingsViewModel @AssistedInject constructor( data object GetProPlan: Commands data object ConfirmProPlan: Commands - data object OnHeaderClicked: Commands + data class OnHeaderClicked(val inSheet: Boolean): Commands data object OnProStatsClicked: Commands } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 80acdb84d0..fe78e52674 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -362,7 +362,7 @@ fun SessionProCTA( BoxWithConstraints(modifier = modifier) { val topInset = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() val maxHeight = - (this.maxHeight - topInset) * 0.8f // sheet should take up 80% of the height, without the status bar + (this.maxHeight - topInset) * 0.85f // sheet should take up 80% of the height, without the status bar Box( modifier = Modifier From 3d8fb834fa23831da1c1a10bf235a6f021c9981b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 05:16:37 +0000 Subject: [PATCH 092/219] Bump actions/upload-artifact from 4 to 5 (#1642) --- .github/workflows/build_and_test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 931a654365..cc1504b170 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -54,14 +54,14 @@ jobs: - name: Upload build reports regardless if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: build-reports-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/reports if-no-files-found: ignore - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: session-${{ matrix.variant }}-${{ matrix.build_type }} path: app/build/outputs/apk/${{ matrix.variant }}/${{ matrix.build_type }}/*-universal*apk From d07e44ff4686fc58a6f8810c4e827449b3d5c849 Mon Sep 17 00:00:00 2001 From: stfsession Date: Tue, 28 Oct 2025 17:00:59 +1100 Subject: [PATCH 093/219] [Automated] Update translations from Crowdin (#1638) Co-authored-by: ThomasSession <171472362+ThomasSession@users.noreply.github.com> Co-authored-by: jbsession --- app/src/main/res/values/strings.xml | 90 ++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7cc6c082d0..83f7deeac6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ Session + There are no non-admin members in this group. About Accept Copy Account ID @@ -16,8 +17,13 @@ This is your Account ID. Other users can scan it to start a conversation with you. Actual Size Add + + Add Admin + Add Admins + Add Admins Enter the Account ID of the user you are promoting to admin.\n\nTo add multiple users, enter each Account ID separated by a comma. Up to 20 Account IDs can be specified at a time. + Admins cannot be demoted or removed from the group. Admins cannot be removed. {name} and {count} others were promoted to Admin. Promote Admins @@ -42,12 +48,18 @@ {name} was removed as Admin. {name} and {count} others were removed as Admin. {name} and {other_name} were removed as Admin. + + %1$d Admin Selected + %1$d Admins Selected + Sending admin promotion Sending admin promotions Admin Settings + You cannot change your admin status. To leave the group, open the conversation settings and select Leave Group. {name} and {other_name} were promoted to Admin. + Admins +{count} Anonymous App Icon @@ -274,11 +286,17 @@ Community URL Copy Community URL Confirm + Confirm Promotion + Are you sure? Admins cannot be demoted or removed from the group. Contacts Delete Contact Are you sure you want to delete {name} from your contacts? New messages from {name} will arrive as a message request. You don\'t have any contacts yet Select Contacts + + %1$d Contact Selected + %1$d Contacts Selected + User Details Camera Choose an action to start a conversation @@ -486,7 +504,12 @@ Database Error Something went wrong. Please try again later. Error loading {pro} access + {app_name} was unable to search for this ONS. Please check your network connection and try again. An unknown error occurred. + This ONS is not registered. Please check it is correct and try again. + Failed to resend invite to {name} in {group_name} + Failed to resend invite to {name} and {count} others in {group_name} + Failed to resend invite to {name} and {other_name} in {group_name} Failed to download Failures Feedback @@ -540,6 +563,9 @@ Are you sure you want to leave {group_name}? Are you sure you want to leave {group_name}?\n\nThis will remove all members and delete all group content. Failed to leave {group_name} + {name} was invited to join the group. Chat history from the last 14 days was shared. + {name} and {count} others were invited to join the group. Chat history from the last 14 days was shared. + {name} and {other_name} were invited to join the group. Chat history from the last 14 days was shared. {name} left the group. {name} and {count} others left the group. {name} and {other_name} left the group. @@ -551,6 +577,9 @@ {name} and {other_name} were invited to join the group. You and {count} others were invited to join the group. Chat history was shared. You and {other_name} were invited to join the group. Chat history was shared. + Failed to remove {name} from {group_name} + Failed to remove {name} and {count} others from {group_name} + Failed to remove {name} and {other_name} from {group_name} You left the group. Group Members There are no other members in this group. @@ -564,6 +593,7 @@ You have no messages from {group_name}. Send a message to start the conversation! This group has not been updated in over 30 days. You may experience issues sending messages or viewing group information. You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. + You are the only admin in {group_name}.\n\nGroup members and settings cannot be changed without an admin. To leave the group without deleting it, please add a new admin first Pending removal You were promoted to Admin. You and {count} others were promoted to Admin. @@ -613,6 +643,10 @@ Request incognito mode if available. Depending on the keyboard you are using, your keyboard may ignore this request. Info Invalid shortcut + + Invite Contact + Invite Contacts + Invite Failed Invites Failed @@ -621,6 +655,11 @@ The invite could not be sent. Would you like to try again? The invites could not be sent. Would you like to try again? + + Invite Member + Invite Members + + Invite a new member to the group by entering your friend\'s Account ID, ONS or scanning their QR code {icon} Join Later Launch {app_name} automatically when your computer starts up. @@ -664,10 +703,15 @@ Tap to unlock {app_name} is unlocked Logs + Manage Admins Manage Members Manage {pro} Max Media + + %1$d Member Selected + %1$d Members Selected + %1$d member %1$d members @@ -677,7 +721,10 @@ %1$d active members Add Account ID or ONS + Members can only be promoted once they\'ve&#13; +accepted an invite to join the group. Invite Contacts + You don’t have any contacts to invite to this group.\nGo back and invite members using their Account ID or ONS. Send Invite Send Invites @@ -686,8 +733,11 @@ Would you like to share group message history with {name} and {count} others? Would you like to share group message history with {name} and {other_name}? Share message history + Share message history from last&#13; +14 days Share new messages only Invite + Members (Non-Admins) Menu Bar Message Read more @@ -706,6 +756,7 @@ Start a new conversation by entering your friend\'s Account ID or ONS. Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code. + Start a new conversation by entering your friend\'s Account ID, ONS or scanning their QR code {icon} You\'ve got a new message. You\'ve got %1$d new messages. @@ -976,7 +1027,7 @@ Cancellation Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. Two ways to cancel your {pro} access: - Canceling {pro} access will prevent will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. + Canceling {pro} access will prevent automatic renewal from occurring before {pro} expires.\n\nCanceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires. Choose the {pro} access option that\'s right for you.\nLonger access means bigger discounts. Are you sure you want to delete your data from this device?\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts.\n\n{app_pro} cannot be transferred to another account. Please save your Recovery Password to ensure you can restore your {pro} access later. @@ -1044,7 +1095,7 @@ Want to send longer messages again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to use {app_name} to its max potential again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to pin more than 5 conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. - Want to more pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Pro renewal unsuccessful, retrying soon Currently, {pro} access can only be purchased and renewed via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to renew here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} @@ -1076,7 +1127,7 @@ Currently, {pro} access can only be purchased via the {platform_store} or {platform_store_other}. Because you installed {app_name} using the {build_variant}, you\'re not able to upgrade to {pro} here.\n\n{app_name} developers are working hard on alternative payment options to allow users to purchase {pro} access outside of the {platform_store} and {platform_store_other}. {pro} Roadmap {icon} For now, there is only one way to upgrade: For now, there are two ways to upgrade: - You have upgraded to {app_pro}!\n\nThank you for supporting the {network_name}. + You have upgraded to {app_pro}!\nThank you for supporting the {network_name}. Upgrading to {pro} By upgrading, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Want to get more out of {app_name}? Upgrade to {app_pro} for a more powerful messaging experience. @@ -1088,6 +1139,11 @@ Please pick a smaller file. Failed to update profile. Promote + Admins will be able to see the last 14 days of message history and cannot be demoted or removed from the group. + + Promote Member + Promote Members + Promotion Failed Promotions Failed @@ -1145,14 +1201,42 @@ Remind Me Later Remove + + Remove Member + Remove Members + + + Remove member and their messages + Remove member and their messages + Failed to remove password Remove your current password for {app_name}. Locally stored data will be re-encrypted with a randomly generated key, stored on your device. + + Removing member + Removing members + Renew Renewing {pro} Reply Request Refund Request a refund on the {platform} website, using the {platform_account} you signed up for {pro} with. Resend + + Resend Invite + Resend Invites + + + Resend Promotion + Resend Promotions + + + Resending invite + Resending invites + + + Resending promotion + Resending promotions + Loading country information... Restart Resync From be0d710d25b69994ebd73276297a9eaff283e77f Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 29 Oct 2025 15:15:02 +1100 Subject: [PATCH 094/219] giving subscription state to CTA to display dynamic content --- .../thoughtcrime/securesms/InputBarDialogs.kt | 3 +- .../securesms/InputbarViewModel.kt | 11 ++- .../conversation/v2/MessageDetailActivity.kt | 15 +++- .../v2/MessageDetailsViewModel.kt | 18 +++-- .../settings/ConversationSettingsDialogs.kt | 5 +- .../settings/ConversationSettingsViewModel.kt | 19 +++-- .../securesms/home/HomeDialogs.kt | 1 + .../securesms/home/HomeViewModel.kt | 8 +- .../securesms/preferences/SettingsScreen.kt | 7 +- .../securesms/ui/ProComponents.kt | 79 ++++++++++++++++--- .../securesms/ui/UserProfileModal.kt | 21 ++--- .../securesms/util/UserProfileUtils.kt | 19 ++++- 12 files changed, 152 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt index bb8daa7705..91d720e821 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputBarDialogs.kt @@ -63,8 +63,9 @@ fun InputBarDialogs( } // Pro CTA - if (inputBarDialogsState.sessionProCharLimitCTA) { + if (inputBarDialogsState.sessionProCharLimitCTA != null) { LongMessageProCTA( + proSubscription = inputBarDialogsState.sessionProCharLimitCTA.proSubscription, onDismissRequest = {sendCommand(InputbarViewModel.Commands.HideSessionProCTA)} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt index 74ba63918a..6b7fe76595 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/InputbarViewModel.kt @@ -12,6 +12,7 @@ import org.session.libsession.utilities.recipients.isPro import org.session.libsession.utilities.recipients.shouldShowProBadge import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.util.NumberUtil @@ -94,7 +95,7 @@ abstract class InputbarViewModel( fun showSessionProCTA(){ _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = true) + it.copy(sessionProCharLimitCTA = CharLimitCTAData(proStatusManager.subscriptionState.value.type)) } } @@ -165,7 +166,7 @@ abstract class InputbarViewModel( is Commands.HideSessionProCTA -> { _inputBarStateDialogsState.update { - it.copy(sessionProCharLimitCTA = false) + it.copy(sessionProCharLimitCTA = null) } } } @@ -195,7 +196,11 @@ abstract class InputbarViewModel( data class InputBarDialogsState( val showSimpleDialog: SimpleDialogData? = null, - val sessionProCharLimitCTA: Boolean = false + val sessionProCharLimitCTA: CharLimitCTAData? = null + ) + + data class CharLimitCTAData( + val proSubscription: SubscriptionType ) sealed interface Commands { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index a94a63388d..07a3f7a709 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -706,13 +706,22 @@ fun MessageDetailDialogs( if(state.proBadgeCTA != null){ when(state.proBadgeCTA){ is ProBadgeCTA.Generic -> - GenericProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + GenericProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.LongMessage -> - LongMessageProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + LongMessageProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) is ProBadgeCTA.AnimatedProfile -> - AnimatedProfilePicProCTA(onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)}) + AnimatedProfilePicProCTA( + proSubscription = state.proBadgeCTA.proSubscription, + onDismissRequest = {sendCommand(Commands.HideProBadgeCTA)} + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt index 9319d0049a..1f4301bc4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailsViewModel.kt @@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.AnimatedAvatar import org.thoughtcrime.securesms.pro.ProStatusManager.MessageProFeature.LongMessage +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.TitledText import org.thoughtcrime.securesms.util.AvatarUIData @@ -283,13 +284,14 @@ class MessageDetailsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { val features = state.value.proFeatures _dialogState.update { + val proSubscription = proStatusManager.subscriptionState.value.type it.copy( proBadgeCTA = when{ - features.size > 1 -> ProBadgeCTA.Generic // always show the generic cta when there are more than 1 feature + features.size > 1 -> ProBadgeCTA.Generic(proSubscription) // always show the generic cta when there are more than 1 feature - features.contains(LongMessage) -> ProBadgeCTA.LongMessage - features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile - else -> ProBadgeCTA.Generic + features.contains(LongMessage) -> ProBadgeCTA.LongMessage(proSubscription) + features.contains(AnimatedAvatar) -> ProBadgeCTA.AnimatedProfile(proSubscription) + else -> ProBadgeCTA.Generic(proSubscription) } ) } @@ -369,10 +371,10 @@ data class MessageDetailsState( val canDelete: Boolean get() = !readOnly } -sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object LongMessage: ProBadgeCTA - data object AnimatedProfile: ProBadgeCTA +sealed class ProBadgeCTA(open val proSubscription: SubscriptionType) { + data class Generic(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class LongMessage(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class AnimatedProfile(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) } data class DialogsState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt index 41f8a0fe57..ffc8c05635 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsDialogs.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsV import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupDescription import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateGroupName import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.UpdateNickname +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.CTAImage import org.thoughtcrime.securesms.ui.DialogButtonData @@ -232,6 +233,7 @@ fun ConversationSettingsDialogs( // pin CTA if(dialogsState.pinCTA != null){ PinProCTA( + proSubscription = dialogsState.pinCTA.proSubscription, overTheLimit = dialogsState.pinCTA.overTheLimit, onDismissRequest = { sendCommand(HidePinCTADialog) @@ -242,6 +244,7 @@ fun ConversationSettingsDialogs( when(dialogsState.proBadgeCTA){ is ConversationSettingsViewModel.ProBadgeCTA.Generic -> { GenericProCTA( + proSubscription = dialogsState.proBadgeCTA.proSubscription, onDismissRequest = { sendCommand(HideProBadgeCTA) } @@ -438,7 +441,7 @@ fun PreviewCTAGroupDialog() { PreviewTheme { ConversationSettingsDialogs( dialogsState = ConversationSettingsViewModel.DialogsState( - proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group + proBadgeCTA = ConversationSettingsViewModel.ProBadgeCTA.Group(SubscriptionType.NeverSubscribed) ), sendCommand = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt index 0d98c5c126..632877c0b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsViewModel.kt @@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.dependencies.ConfigFactory.Companion.MAX_NAME_ import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.home.HomeActivity import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator @@ -719,7 +720,10 @@ class ConversationSettingsViewModel @AssistedInject constructor( if(totalPins >= maxPins){ // the user has reached the pin limit, show the CTA _dialogState.update { - it.copy(pinCTA = PinProCTA(overTheLimit = totalPins > maxPins)) + it.copy(pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.subscriptionState.value.type + )) } } else { viewModelScope.launch { @@ -1236,8 +1240,8 @@ class ConversationSettingsViewModel @AssistedInject constructor( is Commands.ShowProBadgeCTA -> { _dialogState.update { it.copy( - proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group - else ProBadgeCTA.Generic + proBadgeCTA = if(recipient?.isGroupV2Recipient == true) ProBadgeCTA.Group(proStatusManager.subscriptionState.value.type) + else ProBadgeCTA.Generic(proStatusManager.subscriptionState.value.type) ) } } @@ -1441,12 +1445,13 @@ class ConversationSettingsViewModel @AssistedInject constructor( ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: SubscriptionType ) - sealed interface ProBadgeCTA { - data object Generic: ProBadgeCTA - data object Group: ProBadgeCTA + sealed class ProBadgeCTA(open val proSubscription: SubscriptionType) { + data class Generic(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) + data class Group(override val proSubscription: SubscriptionType): ProBadgeCTA(proSubscription) } data class NicknameDialogData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 31e7c693e7..5dc75d008e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -45,6 +45,7 @@ fun HomeDialogs( if(dialogsState.pinCTA != null){ PinProCTA( overTheLimit = dialogsState.pinCTA.overTheLimit, + proSubscription = dialogsState.pinCTA.proSubscription, onDismissRequest = { sendCommand(HidePinCTADialog) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt index c969576b01..628204e81a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeViewModel.kt @@ -261,7 +261,10 @@ class HomeViewModel @Inject constructor( // the user has reached the pin limit, show the CTA _dialogsState.update { it.copy( - pinCTA = PinProCTA(overTheLimit = totalPins > maxPins) + pinCTA = PinProCTA( + overTheLimit = totalPins > maxPins, + proSubscription = proStatusManager.subscriptionState.value.type + ) ) } } else { @@ -341,7 +344,8 @@ class HomeViewModel @Inject constructor( ) data class PinProCTA( - val overTheLimit: Boolean + val overTheLimit: Boolean, + val proSubscription: SubscriptionType ) data class ProExpiringCTA( 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 693d109e32..738b0e5cdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -395,7 +395,7 @@ fun Settings( // Animated avatar CTA if(uiState.showAnimatedProCTA){ AnimatedProCTA( - isPro = uiState.isPro, + proSubscription = uiState.subscriptionState.type, sendCommand = sendCommand ) } @@ -994,10 +994,10 @@ fun AvatarDialog( @Composable fun AnimatedProCTA( - isPro: Boolean, + proSubscription: SubscriptionType, sendCommand: (SettingsViewModel.Commands) -> Unit, ){ - if(isPro) { + if(proSubscription is SubscriptionType.Active) { SessionProCTA ( title = stringResource(R.string.proActivated), badgeAtStart = true, @@ -1032,6 +1032,7 @@ fun AnimatedProCTA( ) } else { AnimatedProfilePicProCTA( + proSubscription = proSubscription, onDismissRequest = { sendCommand(HideAnimatedProCTA) }, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index fe78e52674..d5d395fcb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -83,8 +84,11 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost +import org.thoughtcrime.securesms.pro.SubscriptionDetails +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.Avatar import org.thoughtcrime.securesms.ui.components.BaseBottomSheet @@ -97,6 +101,8 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.util.AvatarUIData +import java.time.Duration +import java.time.Instant @Composable @@ -550,13 +556,23 @@ fun CTAAnimatedImages( // Reusable generic Pro CTA @Composable fun GenericProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ val context = LocalContext.current + val expired = proSubscription is SubscriptionType.Expired + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_generic_bg, heroImageAnimatedFg = R.drawable.cta_hero_generic_fg, - text = Phrase.from(context,R.string.proUserProfileModalCallToAction) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewMaxPotential) + .put(APP_NAME_KEY, context.getString(R.string.app_name)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proUserProfileModalCallToAction) .put(APP_NAME_KEY, context.getString(R.string.app_name)) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() @@ -575,11 +591,21 @@ fun GenericProCTA( // Reusable long message Pro CTA @Composable fun LongMessageProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is SubscriptionType.Expired + val context = LocalContext.current + SimpleSessionProCTA( heroImage = R.drawable.cta_hero_char_limit, - text = Phrase.from(LocalContext.current, R.string.proCallToActionLongerMessages) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = if(expired) Phrase.from(context,R.string.proRenewLongerMessages) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proCallToActionLongerMessages) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), @@ -597,12 +623,22 @@ fun LongMessageProCTA( // Reusable animated profile pic Pro CTA @Composable fun AnimatedProfilePicProCTA( + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, ){ + val expired = proSubscription is SubscriptionType.Expired + val context = LocalContext.current + AnimatedSessionProCTA( heroImageBg = R.drawable.cta_hero_animated_bg, heroImageAnimatedFg = R.drawable.cta_hero_animated_fg, - text = Phrase.from(LocalContext.current, R.string.proAnimatedDisplayPictureCallToActionDescription) + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text =if(expired) Phrase.from(context,R.string.proRenewAnimatedDisplayPicture) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + else Phrase.from(context, R.string.proAnimatedDisplayPictureCallToActionDescription) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString(), @@ -623,24 +659,41 @@ fun AnimatedProfilePicProCTA( @Composable fun PinProCTA( overTheLimit: Boolean, + proSubscription: SubscriptionType, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ){ + val expired = proSubscription is SubscriptionType.Expired val context = LocalContext.current + + val title = when{ + overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinMoreConversations) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + overTheLimit && !expired -> Phrase.from(context, R.string.proCallToActionPinnedConversations) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + + !overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinFiveConversations) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .format() + .toString() + + else -> Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format() + .toString() + } SimpleSessionProCTA( modifier = modifier, heroImage = R.drawable.cta_hero_pins, - text = if(overTheLimit) - Phrase.from(context, R.string.proCallToActionPinnedConversations) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString() - else - Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format() - .toString(), + title = if(expired) stringResource(R.string.renew) + else stringResource(R.string.upgradeTo), + text = title, features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt index 811532e20a..dae5298cfb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UserProfileModal.kt @@ -38,6 +38,7 @@ import org.session.libsession.utilities.Address import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2 +import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.components.SlimAccentOutlineButton import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.ui.theme.monospace import org.thoughtcrime.securesms.ui.theme.primaryRed import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUIElement +import org.thoughtcrime.securesms.util.GenericCTAData import org.thoughtcrime.securesms.util.UserProfileModalCommands import org.thoughtcrime.securesms.util.UserProfileModalData @@ -220,8 +222,9 @@ fun UserProfileModal( ) // the pro CTA that comes with UPM - if(data.showProCTA){ + if(data.showProCTA != null){ GenericProCTA( + proSubscription = data.showProCTA.proSubscription, onDismissRequest = { sendCommand(UserProfileModalCommands.HideSessionProCTA) }, @@ -250,7 +253,7 @@ private fun PreviewUPM( enableMessage = true, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -270,10 +273,10 @@ private fun PreviewUPM( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -310,7 +313,7 @@ private fun PreviewUPMResolved( enableMessage = true, expandedAvatar = false, showQR = true, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -330,10 +333,10 @@ private fun PreviewUPMResolved( sendCommand = { command -> when(command){ UserProfileModalCommands.ShowProCTA -> { - data = data.copy(showProCTA = true) + data = data.copy(showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed)) } UserProfileModalCommands.HideSessionProCTA -> { - data = data.copy(showProCTA = false) + data = data.copy(showProCTA = null) } UserProfileModalCommands.ToggleQR -> { data = data.copy(showQR = !data.showQR) @@ -371,7 +374,7 @@ private fun PreviewUPMQR( enableMessage = false, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, avatarUIData = AvatarUIData( listOf( AvatarUIElement( @@ -413,7 +416,7 @@ private fun PreviewUPMCTA( enableMessage = false, expandedAvatar = true, showQR = false, - showProCTA = true, + showProCTA = GenericCTAData(SubscriptionType.NeverSubscribed), avatarUIData = AvatarUIData( listOf( AvatarUIElement( diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt index 8b09a6a485..9acc453a4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UserProfileUtils.kt @@ -29,6 +29,8 @@ import org.session.libsession.utilities.toBlinded import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.pro.SubscriptionType /** * Helper class to get the information required for the user profile modal @@ -41,6 +43,7 @@ class UserProfileUtils @AssistedInject constructor( private val avatarUtils: AvatarUtils, private val blindedIdMappingRepository: BlindMappingRepository, private val recipientRepository: RecipientRepository, + private val proStatusManager: ProStatusManager ) { private val _userProfileModalData: MutableStateFlow = MutableStateFlow(null) val userProfileModalData: StateFlow @@ -123,7 +126,7 @@ class UserProfileUtils @AssistedInject constructor( enableMessage = !recipient.address.isBlinded || recipient.acceptsBlindedCommunityMessageRequests, expandedAvatar = false, showQR = false, - showProCTA = false, + showProCTA = null, messageAddress = messageAddress, ) @@ -140,11 +143,15 @@ class UserProfileUtils @AssistedInject constructor( fun onCommand(command: UserProfileModalCommands){ when(command){ UserProfileModalCommands.ShowProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = true) } + _userProfileModalData.update { + _userProfileModalData.value?.copy( + showProCTA = GenericCTAData(proStatusManager.subscriptionState.value.type) + ) + } } UserProfileModalCommands.HideSessionProCTA -> { - _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = false) } + _userProfileModalData.update { _userProfileModalData.value?.copy(showProCTA = null) } } UserProfileModalCommands.ToggleQR -> { @@ -196,7 +203,11 @@ data class UserProfileModalData( val expandedAvatar: Boolean, val showQR: Boolean, val avatarUIData: AvatarUIData, - val showProCTA: Boolean + val showProCTA: GenericCTAData? +) + +data class GenericCTAData( + val proSubscription: SubscriptionType ) sealed interface UserProfileModalCommands { From 7cbfb56c2e96563441ac69ab6b35f9f544670095 Mon Sep 17 00:00:00 2001 From: jbsession Date: Wed, 29 Oct 2025 13:19:56 +0800 Subject: [PATCH 095/219] SES-4753 : [Collapsible Footer Action] Manage Members and admins (#1648) * Initial component * fix crash * Slim fill button * fixed bg * collapse ui state * Cleanup and renaming * cleanup * Fixed some animations * String from crowdin * remoed qa placeholder * updated behavior * Fixed rules for bottomsheet * Slim button textStyle changed to * Expose minWidth for slim fill button * Silde in and out duration changes for collapsible footer * Updated collapse animation * Fixed modifier assignment, slimfill min width set to 0dp * Fixed spacings * Updated state for Collapsible footer * Remove fadingBox * Removed rotation for close button * Initial animation fix * Fixed animation * Fixed button mod * inline modifier * animation tweaks --------- Co-authored-by: ThomasSession --- .../groups/SelectContactsViewModel.kt | 38 ++ .../groups/compose/InviteContactsScreen.kt | 112 +++-- .../group/CreateGroupViewModel.kt | 1 + .../preferences/BlockedContactsViewModel.kt | 1 + .../thoughtcrime/securesms/ui/Components.kt | 387 ++++++++++++++++-- .../securesms/ui/components/Button.kt | 25 +- .../securesms/ui/components/ButtonStyle.kt | 2 +- .../src/main/res/values/strings.xml | 2 + 8 files changed, 495 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 5784b8a723..cc371c5164 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -1,11 +1,13 @@ package org.thoughtcrime.securesms.groups +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -15,11 +17,14 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import network.loki.messenger.R import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.shouldShowProBadge @@ -27,6 +32,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.home.search.searchName import org.thoughtcrime.securesms.pro.ProStatusManager +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.util.AvatarUIData import org.thoughtcrime.securesms.util.AvatarUtils @@ -39,6 +45,7 @@ open class SelectContactsViewModel @AssistedInject constructor( @Assisted private val excludingAccountIDs: Set
, @Assisted private val contactFiltering: (Recipient) -> Boolean, // default will filter out blocked and unapproved contacts private val recipientRepository: RecipientRepository, + @param:ApplicationContext private val context: Context, ) : ViewModel() { // Input: The search query private val mutableSearchQuery = MutableStateFlow("") @@ -71,6 +78,27 @@ open class SelectContactsViewModel @AssistedInject constructor( val currentSelected: Set
get() = mutableSelectedContactAccountIDs.value + private val footerCollapsed = MutableStateFlow(false) + + val collapsibleFooterState: StateFlow = + combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed -> + val count = selected.size + val visible = count > 0 + val title = if (count == 0) GetString("") + else GetString( + context.resources.getQuantityString(R.plurals.contactSelected, count, count) + ) + + CollapsibleFooterState( + visible = visible, + // auto-expand when nothing is selected, otherwise keep user's choice + collapsed = if (!visible) false else isCollapsed, + footerActionTitle = title + ) + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState()) + @OptIn(ExperimentalCoroutinesApi::class) private fun observeContacts() = (configFactory.configUpdateNotifications as Flow) .debounce(100L) @@ -144,6 +172,16 @@ open class SelectContactsViewModel @AssistedInject constructor( mutableSelectedContactAccountIDs.value = emptySet() } + fun toggleFooter() { + footerCollapsed.update { !it } + } + + data class CollapsibleFooterState( + val visible: Boolean = false, + val collapsed: Boolean = false, + val footerActionTitle : GetString = GetString("") + ) + @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt index 902c0bc4fe..b0ece45a94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt @@ -4,10 +4,16 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api @@ -15,8 +21,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import network.loki.messenger.R @@ -24,9 +32,12 @@ import org.session.libsession.utilities.Address import org.thoughtcrime.securesms.groups.ContactItem import org.thoughtcrime.securesms.groups.SelectContactsViewModel import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox +import org.thoughtcrime.securesms.ui.CollapsibleFooterAction +import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData +import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData +import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.SearchBar import org.thoughtcrime.securesms.ui.components.BackAppBar -import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -42,17 +53,23 @@ fun InviteContactsScreen( viewModel: SelectContactsViewModel, onDoneClicked: () -> Unit, onBack: () -> Unit, - banner: @Composable ()->Unit = {} + banner: @Composable () -> Unit = {} ) { + val footerData by viewModel.collapsibleFooterState.collectAsState() + InviteContacts( contacts = viewModel.contacts.collectAsState().value, onContactItemClicked = viewModel::onContactItemClicked, searchQuery = viewModel.searchQuery.collectAsState().value, onSearchQueryChanged = viewModel::onSearchQueryChanged, - onSearchQueryClear = {viewModel.onSearchQueryChanged("") }, + onSearchQueryClear = { viewModel.onSearchQueryChanged("") }, onDoneClicked = onDoneClicked, onBack = onBack, - banner = banner + banner = banner, + data = footerData, + onToggleFooter = viewModel::toggleFooter, + onCloseFooter = viewModel::clearSelection + ) } @@ -66,15 +83,48 @@ fun InviteContacts( onSearchQueryClear: () -> Unit, onDoneClicked: () -> Unit, onBack: () -> Unit, - banner: @Composable ()->Unit = {} + banner: @Composable () -> Unit = {}, + data: SelectContactsViewModel.CollapsibleFooterState, + onToggleFooter: () -> Unit, + onCloseFooter: () -> Unit, ) { + val colors = LocalColors.current + val trayItems = listOf( + CollapsibleFooterItemData( + label = GetString(LocalResources.current.getString(R.string.membersInvite)), + buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)), + buttonColor = colors.accent, + onClick = { onDoneClicked() } + ) + ) + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, topBar = { BackAppBar( title = stringResource(id = R.string.membersInvite), onBack = onBack, ) }, + bottomBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)) + .imePadding() + ) { + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = data.footerActionTitle, + collapsed = data.collapsed, + visible = data.visible, + items = trayItems + ), + onCollapsedClicked = onToggleFooter, + onClosedClicked = onCloseFooter + ) + } + } ) { paddings -> Column( modifier = Modifier @@ -100,18 +150,19 @@ fun InviteContacts( Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding -> - if(contacts.isEmpty() && searchQuery.isEmpty()){ + Box(modifier = Modifier.weight(1f)) { + if (contacts.isEmpty() && searchQuery.isEmpty()) { Text( text = stringResource(id = R.string.contactNone), - modifier = Modifier.padding(top = LocalDimensions.current.spacing) + modifier = Modifier + .padding(top = LocalDimensions.current.spacing) .align(Alignment.TopCenter), style = LocalType.current.base.copy(color = LocalColors.current.textSecondary) ) } else { LazyColumn( state = scrollState, - contentPadding = PaddingValues(bottom = bottomContentPadding), + contentPadding = PaddingValues(bottom = LocalDimensions.current.spacing), ) { multiSelectMemberList( contacts = contacts, @@ -120,27 +171,7 @@ fun InviteContacts( } } } - - Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - AccentOutlineButton( - onClick = onDoneClicked, - enabled = contacts.any { it.selected }, - modifier = Modifier - .padding(vertical = LocalDimensions.current.spacing) - .qaTag(R.string.qa_invite_button), - ) { - Text( - stringResource(id = R.string.membersInviteTitle) - ) - } - } } - } } @@ -174,6 +205,13 @@ private fun PreviewSelectContacts() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = false, + visible = true, + footerActionTitle = GetString("1 Contact Selected") + ), + onToggleFooter = { }, + onCloseFooter = { }, ) } } @@ -192,7 +230,13 @@ private fun PreviewSelectEmptyContacts() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - banner = { GroupMinimumVersionBanner() } + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ), + onToggleFooter = { }, + onCloseFooter = { } ) } } @@ -211,7 +255,13 @@ private fun PreviewSelectEmptyContactsWithSearch() { onSearchQueryClear = {}, onDoneClicked = {}, onBack = {}, - banner = { GroupMinimumVersionBanner() } + data = SelectContactsViewModel.CollapsibleFooterState( + collapsed = true, + visible = false, + footerActionTitle = GetString("") + ), + onToggleFooter = { }, + onCloseFooter = { } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt index 9a5ebeb878..2136ec045d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt @@ -46,6 +46,7 @@ class CreateGroupViewModel @AssistedInject constructor( excludingAccountIDs = emptySet(), contactFiltering = SelectContactsViewModel.Factory.defaultFiltering, recipientRepository = recipientRepository, + context = appContext ) { // Child view model to handle contact selection logic diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt index 4dc04a007c..cf2666cd9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt @@ -34,6 +34,7 @@ class BlockedContactsViewModel @Inject constructor( avatarUtils = avatarUtils, proStatusManager = proStatusManager, recipientRepository = recipientRepository, + context = context ) { private val _unblockDialog = MutableStateFlow(false) val unblockDialog: StateFlow = _unblockDialog diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 14e4d43a06..2ad98b9ace 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -3,13 +3,25 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -17,6 +29,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -26,11 +39,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -44,6 +59,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -55,6 +71,7 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -64,6 +81,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.BlendMode @@ -79,6 +97,7 @@ import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -88,10 +107,11 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.min import androidx.compose.ui.unit.times import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -99,6 +119,7 @@ import kotlinx.coroutines.launch import network.loki.messenger.R import org.thoughtcrime.securesms.ui.components.AccentOutlineButton import org.thoughtcrime.securesms.ui.components.SessionSwitch +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator import org.thoughtcrime.securesms.ui.components.TitledRadioButton import org.thoughtcrime.securesms.ui.components.annotatedStringResource @@ -106,6 +127,8 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.primaryBlue import org.thoughtcrime.securesms.ui.theme.primaryGreen import org.thoughtcrime.securesms.ui.theme.primaryOrange @@ -125,11 +148,11 @@ fun AccountIdHeader( horizontal = LocalDimensions.current.contentSpacing, vertical = LocalDimensions.current.xxsSpacing ) -){ +) { Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - ){ + ) { Box( modifier = Modifier .weight(1f) @@ -142,8 +165,7 @@ fun AccountIdHeader( .border( shape = MaterialTheme.shapes.large ) - .padding(textPaddingValues) - , + .padding(textPaddingValues), text = text, style = textStyle.copy(color = LocalColors.current.textSecondary) ) @@ -202,7 +224,7 @@ fun PathDot( @Preview @Composable -fun PreviewPathDot(){ +fun PreviewPathDot() { PreviewTheme { Box( modifier = Modifier.padding(20.dp) @@ -227,8 +249,11 @@ data class OptionsCardData( val title: GetString?, val options: List> ) { - constructor(title: GetString, vararg options: RadioOption): this(title, options.asList()) - constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList()) + constructor(title: GetString, vararg options: RadioOption) : this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: RadioOption) : this( + GetString(title), + options.asList() + ) } @Composable @@ -286,7 +311,8 @@ fun ItemButton( painter = painterResource(id = iconRes), contentDescription = null, tint = iconTint ?: colors.contentColor, - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) .size(iconSize) ) }, @@ -318,7 +344,8 @@ fun ItemButton( onClick: () -> Unit ) { TextButton( - modifier = modifier.fillMaxWidth() + modifier = modifier + .fillMaxWidth() .heightIn(min = minHeight), colors = colors, onClick = onClick, @@ -351,14 +378,15 @@ fun ItemButton( subtitle?.let { Text( text = it, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .qaTag(subtitleQaTag), style = LocalType.current.small, ) } } - endIcon?.let{ + endIcon?.let { Spacer(Modifier.width(LocalDimensions.current.smallSpacing)) Box( @@ -392,7 +420,7 @@ fun Cell( Box( modifier = modifier .then( - if(dropShadow) + if (dropShadow) Modifier.dropShadow( shape = MaterialTheme.shapes.small, shadow = Shadow( @@ -425,7 +453,7 @@ fun getCellTopShape() = RoundedCornerShape( @Composable fun getCellBottomShape() = RoundedCornerShape( - topStart = 0.dp, + topStart = 0.dp, topEnd = 0.dp, bottomEnd = LocalDimensions.current.shapeSmall, bottomStart = LocalDimensions.current.shapeSmall @@ -439,11 +467,11 @@ fun CategoryCell( dropShadow: Boolean = false, content: @Composable () -> Unit, -){ + ) { Column( modifier = modifier.fillMaxWidth() ) { - if(!title.isNullOrEmpty() || titleIcon != null) { + if (!title.isNullOrEmpty() || titleIcon != null) { Row( modifier = Modifier.padding( start = LocalDimensions.current.smallSpacing, @@ -464,12 +492,12 @@ fun CategoryCell( } } - Cell( - modifier = Modifier.fillMaxWidth(), - dropShadow = dropShadow - ){ + Cell( + modifier = Modifier.fillMaxWidth(), + dropShadow = dropShadow + ) { content() - } + } } } @@ -510,9 +538,11 @@ private fun BottomFadingEdgeBoxPreview() { content = { bottomContentPadding -> LazyColumn(contentPadding = PaddingValues(bottom = bottomContentPadding)) { items(200) { - Text("Item $it", + Text( + "Item $it", color = LocalColors.current.text, - style = LocalType.current.base) + style = LocalType.current.base + ) } } }, @@ -631,7 +661,7 @@ fun SpeechBubbleTooltip( state = tooltipState, modifier = modifier, positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { + tooltip = { val bubbleColor = LocalColors.current.backgroundBubbleReceived Card( @@ -731,6 +761,279 @@ fun SearchBar( ) } +/** + * CollapsibleFooterAction + */ +@Composable +fun CollapsibleFooterAction( + modifier: Modifier = Modifier, + data: CollapsibleFooterActionData, + onCollapsedClicked: () -> Unit = {}, + onClosedClicked: () -> Unit = {} +) { + + // Bottomsheet-like enter/exit + val enterFromBottom = remember { + slideInVertically( + // start completely off-screen below + initialOffsetY = { it }, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing) + ) + fadeIn() + } + val exitToBottom = remember { + slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing) + ) + fadeOut() + } + + AnimatedVisibility( + // drives show/hide from bottom + visible = data.visible, + enter = enterFromBottom, + exit = exitToBottom, + ) { + Column( + modifier = modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = LocalDimensions.current.contentSpacing, + topEnd = LocalDimensions.current.contentSpacing + ) + ) + .background(LocalColors.current.backgroundSecondary) + .animateContentSize() + .padding( + horizontal = LocalDimensions.current.smallSpacing, + vertical = LocalDimensions.current.xxsSpacing + ), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val rotation by animateFloatAsState( + targetValue = if (data.collapsed) 180f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + + IconButton( + modifier = Modifier.rotate(rotation), + onClick = onCollapsedClicked + ) { + Icon( + painter = painterResource(R.drawable.ic_chevron_down), + contentDescription = null + ) + } + Text( + text = data.title.string(), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = LocalDimensions.current.smallSpacing), + style = LocalType.current.h8, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IconButton( + onClick = onClosedClicked + ) { + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = null + ) + } + } + + val showActions = data.visible && !data.collapsed + // Rendered actions + AnimatedVisibility( + visible = showActions, + enter = expandVertically( + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top + ) + fadeIn(animationSpec = tween(durationMillis = 120)), + exit = shrinkVertically( + animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing), + shrinkTowards = Alignment.Top + ) + fadeOut(animationSpec = tween(durationMillis = 80)) + ) { + CategoryCell(modifier = Modifier.padding(bottom = LocalDimensions.current.smallSpacing)) { + CollapsibleFooterActions(items = data.items) + } + } + } + } +} + +@Composable +private fun CollapsibleFooterActions( + items: List, + buttonWidthCapFraction: Float = 1f / 3f // criteria +) { + // rules for this: + // Max width should be approx 1/3 of the available space (buttonWidthCapFraction) + // Buttons should have matching widths + + BoxWithConstraints(Modifier.fillMaxWidth()) { + val density = LocalDensity.current + val capPx = (constraints.maxWidth * buttonWidthCapFraction).toInt() + val capDp = with(density) { capPx.toDp() } + + val single = items.size == 1 + val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) } + + // Only do the offscreen equal width computation when we have 2+ buttons. + if (!single) { + SubcomposeLayout { parentConstraints -> + val measurables = subcompose("measureButtons") { + items.forEach { item -> + SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {} + } + } + val placeables = measurables.map { m -> + m.measure( + Constraints( + minWidth = 1, + maxWidth = capPx, + minHeight = 0, + maxHeight = parentConstraints.maxHeight + ) + ) + } + val natural = placeables.maxOfOrNull { it.width } ?: 1 + measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx) + layout(0, 0) {} + } + } + + val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(LocalColors.current.backgroundTertiary) + ) { + items.forEachIndexed { index, item -> + if (index != 0) Divider() + + val titleText = item.label() + val annotatedTitle = remember(titleText) { AnnotatedString(titleText) } + + ActionRowItem( + modifier = Modifier.background(LocalColors.current.backgroundTertiary), + title = annotatedTitle, + onClick = {}, + qaTag = R.string.qa_collapsing_footer_action, + endContent = { + Box( + modifier = Modifier + .padding(start = LocalDimensions.current.smallSpacing) + .then( + if (single) Modifier.wrapContentWidth().widthIn(max = capDp) + else Modifier.width(equalWidthDp) + ) + ) { + SlimFillButtonRect( + modifier = if (single) Modifier else Modifier.fillMaxWidth(), + text = item.buttonLabel.string(), + color = item.buttonColor + ) { item.onClick() } + } + } + ) + } + } + } +} + +data class CollapsibleFooterActionData( + val title: GetString, + val collapsed: Boolean, + val visible: Boolean, + val items: List +) + +data class CollapsibleFooterItemData( + val label: GetString, + val buttonLabel: GetString, + val buttonColor: Color, + val onClick: () -> Unit +) + + +@Preview +@Composable +fun PreviewCollapsibleActionTray( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val demoItems = listOf( + CollapsibleFooterItemData( + label = GetString("Invite "), + buttonLabel = GetString("Invite"), + buttonColor = LocalColors.current.accent, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Delete"), + buttonLabel = GetString("2"), + buttonColor = LocalColors.current.danger, + onClick = {} + ) + ) + + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = GetString("Invite Contacts"), + collapsed = false, + visible = true, + items = demoItems + ) + ) + } +} + +@Preview +@Composable +fun PreviewCollapsibleActionTrayLongText( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + val demoItems = listOf( + CollapsibleFooterItemData( + label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"), + buttonLabel = GetString("Long Looooooooooooooooooooong"), + buttonColor = LocalColors.current.accent, + onClick = {} + ), + CollapsibleFooterItemData( + label = GetString("Delete"), + buttonLabel = GetString("Delete"), + buttonColor = LocalColors.current.danger, + onClick = {} + ) + ) + + CollapsibleFooterAction( + data = CollapsibleFooterActionData( + title = GetString("Invite Contacts"), + collapsed = false, + visible = true, + items = demoItems + ) + ) + } +} + @Preview @Composable fun PreviewSearchBar() { @@ -761,14 +1064,15 @@ fun ExpandableText( expandedMaxLines: Int = Int.MAX_VALUE, expandButtonText: String = stringResource(id = R.string.viewMore), collapseButtonText: String = stringResource(id = R.string.viewLess), -){ +) { var expanded by remember { mutableStateOf(false) } var showButton by remember { mutableStateOf(false) } var maxHeight by remember { mutableStateOf(Dp.Unspecified) } val density = LocalDensity.current - val enableScrolling = expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE + val enableScrolling = + expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE BaseExpandableText( text = text, @@ -789,10 +1093,11 @@ fun ExpandableText( onTextMeasured = { textLayoutResult -> showButton = expanded || textLayoutResult.hasVisualOverflow val lastVisible = (expandedMaxLines - 1).coerceAtMost(textLayoutResult.lineCount - 1) - val px = textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px + val px = + textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px maxHeight = with(density) { px.toDp() } }, - onTap = if(showButton){ // only expand if there is enough text + onTap = if (showButton) { // only expand if there is enough text { expanded = !expanded } } else null ) @@ -851,11 +1156,11 @@ fun BaseExpandableText( showScroll: Boolean = false, onTextMeasured: (TextLayoutResult) -> Unit = {}, onTap: (() -> Unit)? = null -){ +) { var textModifier: Modifier = Modifier - if(qaTag != null) textModifier = textModifier.qaTag(qaTag) - if(expanded) textModifier = textModifier.height(expandedMaxHeight) - if(showScroll){ + if (qaTag != null) textModifier = textModifier.qaTag(qaTag) + if (expanded) textModifier = textModifier.height(expandedMaxHeight) + if (showScroll) { val scrollState = rememberScrollState() val scrollEdge = LocalDimensions.current.xxxsSpacing val scrollWidth = 2.dp @@ -871,7 +1176,7 @@ fun BaseExpandableText( Column( modifier = modifier.then( - if(onTap != null) Modifier.clickable { onTap() } else Modifier + if (onTap != null) Modifier.clickable { onTap() } else Modifier ), horizontalAlignment = Alignment.CenterHorizontally ) { @@ -888,7 +1193,7 @@ fun BaseExpandableText( overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis ) - if(showButton) { + if (showButton) { Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) Text( text = if (expanded) collapseButtonText else expandButtonText, @@ -1018,9 +1323,10 @@ fun ActionRowItem( minHeight: Dp = LocalDimensions.current.minItemButtonHeight, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), endContent: @Composable (() -> Unit)? = null -){ +) { Row( - modifier = modifier.heightIn(min = minHeight) + modifier = modifier + .heightIn(min = minHeight) .clickable { onClick() } .padding(paddingValues) .qaTag(qaTag), @@ -1074,7 +1380,7 @@ fun IconActionRowItem( iconSize: Dp = LocalDimensions.current.iconMedium, minHeight: Dp = LocalDimensions.current.minItemButtonHeight, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), -){ +) { ActionRowItem( modifier = modifier, title = title, @@ -1092,7 +1398,8 @@ fun IconActionRowItem( modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing) ) { Icon( - modifier = Modifier.align(Alignment.Center) + modifier = Modifier + .align(Alignment.Center) .size(iconSize) .qaTag(R.string.qa_action_item_icon), painter = painterResource(id = icon), @@ -1118,7 +1425,7 @@ fun SwitchActionRowItem( subtitleStyle: TextStyle = LocalType.current.small, paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing), minHeight: Dp = LocalDimensions.current.minItemButtonHeight, -){ +) { ActionRowItem( modifier = modifier, title = title, @@ -1142,7 +1449,7 @@ fun SwitchActionRowItem( @Preview @Composable -fun PreviewActionRowItems(){ +fun PreviewActionRowItems() { PreviewTheme { Column( modifier = Modifier.padding(20.dp), diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt index ef9215cace..d54ceceb30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt @@ -73,7 +73,8 @@ fun Button( style.applyButtonConstraints { androidx.compose.material3.Button( onClick = onClick, - modifier = modifier.heightIn(min = style.minHeight) + modifier = modifier + .heightIn(min = style.minHeight) .defaultMinSize(minWidth = minWidth), enabled = enabled, interactionSource = interactionSource, @@ -352,6 +353,27 @@ fun BorderlessHtmlButton( } } +@Composable +fun SlimFillButtonRect( + text: String, + modifier: Modifier = Modifier, + color: Color = LocalColors.current.accent, + enabled: Boolean = true, + minWidth: Dp = 0.dp, + onClick: () -> Unit +) { + Button( + text, onClick, + ButtonType.Fill(color), + modifier, + enabled, + style = ButtonStyle.Slim, + shape = sessionShapes().extraSmall, + minWidth = minWidth + ) +} + + val MutableInteractionSource.releases get() = interactions.filter { it is PressInteraction.Release } @@ -380,6 +402,7 @@ private fun VariousButtons( SlimOutlineButton("Slim Outline Disabled", enabled = false) {} SlimAccentOutlineButton("Slim Accent") {} SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {} + SlimFillButtonRect("Slim Fill", color = LocalColors.current.accent) {} BorderlessButton("Borderless Button") {} BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {} FillButtonRect("Fill Rect") {} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt index bd4a855fb7..e0b67fa2ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt @@ -52,7 +52,7 @@ interface ButtonStyle { object Slim: ButtonStyle { @Composable - override fun textStyle() = LocalType.current.extraSmall.bold() + override fun textStyle() = LocalType.current.small.bold() .copy(textAlign = TextAlign.Center) override val minHeight = 29.dp } diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index dcd853dd0c..a727f999e5 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -291,4 +291,6 @@ action-item-icon qa-blocked-contacts-settings-item + qa-collapsing-footer-action + \ No newline at end of file From 054e01ea9659aca79616c7fc4da2968d4f47cc10 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 30 Oct 2025 12:53:53 +1100 Subject: [PATCH 096/219] Rely on subscription state instead of simple boolean for Pro status --- .../org/thoughtcrime/securesms/preferences/SettingsScreen.kt | 5 ++--- .../thoughtcrime/securesms/preferences/SettingsViewModel.kt | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) 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 738b0e5cdf..17089d4741 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsScreen.kt @@ -267,7 +267,7 @@ fun Settings( }, text = uiState.username, iconSize = 53.sp to 24.sp, - content = if(uiState.isPro){{ + content = if(uiState.subscriptionState.type !is SubscriptionType.NeverSubscribed){{ // if we are pro or expired ProBadge( modifier = Modifier.padding(start = 4.dp) .qaTag(stringResource(R.string.qa_pro_badge_icon)), @@ -385,7 +385,7 @@ fun Settings( if(uiState.showAvatarDialog) { AvatarDialog( state = uiState.avatarDialogState, - isPro = uiState.isPro, + isPro = uiState.subscriptionState.type is SubscriptionType.Active, isPostPro = uiState.isPostPro, sendCommand = sendCommand, startAvatarSelection = startAvatarSelection @@ -1062,7 +1062,6 @@ private fun SettingsScreenPreview() { ) ) ), - isPro = true, isPostPro = true, subscriptionState = SubscriptionState( type = SubscriptionType.Active.AutoRenewing( 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 87b0e6c8c7..ec25ec3788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -96,7 +96,6 @@ class SettingsViewModel @Inject constructor( hasPath = true, version = getVersionNumber(), recoveryHidden = prefs.getHidePassword(), - isPro = selfRecipient.value.proStatus.isPro(), isPostPro = proStatusManager.isPostPro(), subscriptionState = getDefaultSubscriptionStateData(), )) @@ -111,7 +110,6 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy( username = recipient.displayName(attachesBlindedId = false), - isPro = recipient.proStatus.isPro(), ) } } @@ -633,7 +631,6 @@ class SettingsViewModel @Inject constructor( val showAnimatedProCTA: Boolean = false, val usernameDialog: UsernameDialogData? = null, val showSimpleDialog: SimpleDialogData? = null, - val isPro: Boolean, val isPostPro: Boolean, val subscriptionState: SubscriptionState, ) From fa92657873eb3d093c6be2fa5b3d5f4b5a02d035 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 30 Oct 2025 16:06:15 +1100 Subject: [PATCH 097/219] Renaming subscription methods in preparation for price calculation --- .../prosettings/ProSettingsViewModel.kt | 16 +++----- .../chooseplan/ChoosePlanScreen.kt | 5 +-- .../subscription/NoOpSubscriptionManager.kt | 2 +- .../subscription/ProSubscriptionDuration.kt | 11 +++-- .../pro/subscription/SubscriptionManager.kt | 9 ++++- .../PlayStoreSubscriptionManager.kt | 40 +++++++++---------- 6 files changed, 42 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 26f4814934..633f85f137 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -36,7 +36,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY -import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -90,7 +89,7 @@ class ProSettingsViewModel @AssistedInject constructor( // observe purchase events viewModelScope.launch { subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> - _choosePlanState.update { it.copy(loading = false) } + _choosePlanState.update { it.copy(purchaseInProgress = false) } when(purchaseEvent){ is SubscriptionManager.PurchaseEvent.Success -> { @@ -125,7 +124,7 @@ class ProSettingsViewModel @AssistedInject constructor( ChoosePlanState( subscriptionType = subType, - hasValidSubscription = proState.hasValidSubscription, + hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(), hasBillingCapacity = supportsBilling, enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state plans = listOf( @@ -243,7 +242,7 @@ class ProSettingsViewModel @AssistedInject constructor( } } - private suspend fun generateState(subscriptionState: SubscriptionState){ + private fun generateState(subscriptionState: SubscriptionState){ //todo PRO need to properly calculate this val subType = subscriptionState.type @@ -251,8 +250,6 @@ class ProSettingsViewModel @AssistedInject constructor( _proSettingsUIState.update { ProSettingsState( subscriptionState = subscriptionState, - //todo PRO need to get the product id from libsession - also this might be a long running operation - hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(""), subscriptionExpiryLabel = when(subType){ is SubscriptionType.Active.AutoRenewing -> Phrase.from(context, R.string.proAutoRenewTime) @@ -634,7 +631,7 @@ class ProSettingsViewModel @AssistedInject constructor( if(purchaseStarted.isSuccess) { _choosePlanState.update { - it.copy(loading = true) + it.copy(purchaseInProgress = true) } } } @@ -679,16 +676,15 @@ class ProSettingsViewModel @AssistedInject constructor( data class ProSettingsState( val subscriptionState: SubscriptionState = getDefaultSubscriptionStateData(), val proStats: State = State.Loading, - val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val subscriptionExpiryLabel: CharSequence = "", // eg: "Pro auto renewing in 3 days" val subscriptionExpiryDate: CharSequence = "" // eg: "May 21st, 2025" ) data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasBillingCapacity: Boolean = false, + val hasBillingCapacity: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val hasValidSubscription: Boolean = false, - val loading: Boolean = false, + val purchaseInProgress: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt index 6041b01578..7cc98f1380 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -157,7 +156,7 @@ fun ChoosePlan( proPlan = data, badgePadding = badgeHeight / 2, onBadgeLaidOut = { height -> badgeHeight = max(badgeHeight, height) }, - enabled = !planData.loading, + enabled = !planData.purchaseInProgress, onClick = { sendCommand(SelectProPlan(data)) } @@ -185,7 +184,7 @@ fun ChoosePlan( sendCommand(GetProPlan) } ){ - LoadingArcOr(loading = planData.loading) { + LoadingArcOr(loading = planData.purchaseInProgress) { Text(text = buttonLabel) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 967056de8a..9d1e64a615 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -29,7 +29,7 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val purchaseEvents: SharedFlow = MutableSharedFlow() - override suspend fun hasValidSubscription(productId: String): Boolean { + override suspend fun hasValidSubscription(): Boolean { return false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt index 5f1b7b1ace..e07be952ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/ProSubscriptionDuration.kt @@ -5,12 +5,15 @@ import java.time.Duration import java.time.Period import java.time.ZonedDateTime -enum class ProSubscriptionDuration(val duration: Period) { - ONE_MONTH(Period.ofMonths(1)), - THREE_MONTHS(Period.ofMonths(3)), - TWELVE_MONTHS(Period.ofMonths(12)) +enum class ProSubscriptionDuration(val duration: Period, val id: String) { + ONE_MONTH(Period.ofMonths(1), "session-pro-1-month"), + THREE_MONTHS(Period.ofMonths(3), "session-pro-3-months"), + TWELVE_MONTHS(Period.ofMonths(12), "session-pro-12-months") } +fun ProSubscriptionDuration.getById(id: String): ProSubscriptionDuration? = + ProSubscriptionDuration.entries.find { it.id == id } + private val proSettingsDateFormat = "MMMM d, yyyy" fun ProSubscriptionDuration.expiryFromNow(): String { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 3a516c5012..e44d6691d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -41,8 +41,13 @@ interface SubscriptionManager: OnAppStartupComponent { suspend fun isWithinQuickRefundWindow(): Boolean /** - * Checks whether there is a valid subscription for the given product id for the current user within this subscriber's billing API + * Checks whether there is a valid subscription for the current user within this subscriber's billing API */ - suspend fun hasValidSubscription(productId: String): Boolean + suspend fun hasValidSubscription(): Boolean + + data class SubscriptionPricing( + val subscriptionDuration: ProSubscriptionDuration, + val price: String, + ) } diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 511066a1d6..2aa8b8d689 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -8,6 +8,7 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchasesParams @@ -43,6 +44,7 @@ import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.measureTime /** * The Google Play Store implementation of our subscription manager @@ -92,7 +94,6 @@ class PlayStoreSubscriptionManager @Inject constructor( // signal that purchase was completed try { //todo PRO send confirmation to libsession - delay(4000) } catch (e : Exception){ _purchaseEvents.emit(PurchaseEvent.Failed()) } @@ -126,18 +127,7 @@ class PlayStoreSubscriptionManager @Inject constructor( "No current activity available to launch the billing flow" } - val result = billingClient.queryProductDetails( - QueryProductDetailsParams.newBuilder() - .setProductList( - listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("session_pro") - .setProductType(BillingClient.ProductType.SUBS) - .build() - ) - ) - .build() - ) + val result = getProductDetails() check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { "Failed to query product details. Reason: ${result.billingResult}" @@ -147,7 +137,7 @@ class PlayStoreSubscriptionManager @Inject constructor( "Unable to get the product: product for given id is null" } - val planId = subscriptionDuration.planId + val planId = subscriptionDuration.id val offerDetails = checkNotNull(productDetails.subscriptionOfferDetails ?.firstOrNull { it.basePlanId == planId }) { @@ -206,12 +196,20 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - private val ProSubscriptionDuration.planId: String - get() = when (this) { - ProSubscriptionDuration.ONE_MONTH -> "session-pro-1-month" - ProSubscriptionDuration.THREE_MONTHS -> "session-pro-3-months" - ProSubscriptionDuration.TWELVE_MONTHS -> "session-pro-12-months" - } + private suspend fun getProductDetails(): ProductDetailsResult { + return billingClient.queryProductDetails( + QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("session_pro") + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + ) + .build() + ) + } override fun onPostAppStarted() { super.onPostAppStarted() @@ -253,7 +251,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } } - override suspend fun hasValidSubscription(productId: String): Boolean { + override suspend fun hasValidSubscription(): Boolean { // if in debug mode, always return true return if(prefs.forceCurrentUserAsPro()) true else getExistingSubscription() != null From 150ca717a5ee952cfeeab5117103cf2103b2fa4d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 10:13:16 +1100 Subject: [PATCH 098/219] Added State management to the choose plan and cancel data --- .../securesms/home/HomeDialogs.kt | 13 +- .../prosettings/CancelPlanScreen.kt | 98 +++--- .../prosettings/ProSettingsNavHost.kt | 26 +- .../prosettings/ProSettingsViewModel.kt | 327 ++++++++++-------- .../{ChoosePlanScreen.kt => ChoosePlan.kt} | 0 .../chooseplan/ChoosePlanHomeScreen.kt | 90 +++++ .../chooseplan/GetOrRenewPlanScreen.kt | 38 -- .../chooseplan/UpdatePlanScreen.kt | 50 --- 8 files changed, 331 insertions(+), 311 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/{ChoosePlanScreen.kt => ChoosePlan.kt} (100%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt index 5dc75d008e..036d3822b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeDialogs.kt @@ -1,38 +1,29 @@ package org.thoughtcrime.securesms.home -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import com.squareup.phrase.Phrase import kotlinx.coroutines.delay import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants -import org.session.libsession.utilities.NonTranslatableStringConstants.PRO -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HandleUserProfileCommand import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HidePinCTADialog import org.thoughtcrime.securesms.home.HomeViewModel.Commands.HideUserProfileModal -import org.thoughtcrime.securesms.home.startconversation.StartConversationDestination import org.thoughtcrime.securesms.home.startconversation.StartConversationSheet import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.ui.AnimatedSessionProCTA import org.thoughtcrime.securesms.ui.CTAFeature import org.thoughtcrime.securesms.ui.PinProCTA -import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.UserProfileModal -import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme @Composable @@ -106,7 +97,7 @@ fun HomeDialogs( negativeButtonText = stringResource(R.string.close), onUpgrade = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) - sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.UpdatePlan)) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiringCTADialog) @@ -147,7 +138,7 @@ fun HomeDialogs( negativeButtonText = stringResource(R.string.cancel), onUpgrade = { sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) - sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.GetOrRenewPlan)) + sendCommand(HomeViewModel.Commands.GotoProSettings(ProSettingsDestination.ChoosePlan)) }, onCancel = { sendCommand(HomeViewModel.Commands.HideExpiredCTADialog) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index b16d5abd08..b826dfd584 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -1,14 +1,18 @@ package org.thoughtcrime.securesms.preferences.prosettings +import android.widget.Toast import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -19,13 +23,9 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY -import org.session.libsession.utilities.recipients.ProStatus import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.OpenSubscriptionPage -import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.pro.subscription.NoOpSubscriptionManager -import org.thoughtcrime.securesms.pro.subscription.ProSubscriptionDuration -import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator import org.thoughtcrime.securesms.ui.components.annotatedStringResource import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions @@ -34,8 +34,7 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold -import java.time.Duration -import java.time.Instant +import org.thoughtcrime.securesms.util.State @OptIn(ExperimentalSharedTransitionApi::class) @@ -44,42 +43,57 @@ fun CancelPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val planData by viewModel.choosePlanState.collectAsState() - val activePlan = planData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + val state by viewModel.cancelPlanState.collectAsState() - val subManager = viewModel.getSubscriptionManager() + when(state) { + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - activePlan.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription -> - CancelPlanNonOriginating( - subscriptionDetails = activePlan.subscriptionDetails, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } - // default cancel screen - else -> CancelPlan( - data = activePlan, - subscriptionManager = subManager, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + is State.Success -> { + val planData = (state as State.Success).value + val activePlan = planData.subscriptionType as? SubscriptionType.Active + if (activePlan == null) { + onBack() + return + } + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> + CancelPlanNonOriginating( + subscriptionDetails = activePlan.subscriptionDetails, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default cancel screen + else -> CancelPlan( + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } } } @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun CancelPlan( - data: SubscriptionType.Active, - subscriptionManager: SubscriptionManager, sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { @@ -131,22 +145,6 @@ private fun PreviewCancelPlan( ) { PreviewTheme(colors) { CancelPlan( - data = SubscriptionType.Active.AutoRenewing( - proStatus = ProStatus.Pro( - visible = true, - validUntil = Instant.now() + Duration.ofDays(14), - ), - duration = ProSubscriptionDuration.THREE_MONTHS, - subscriptionDetails = SubscriptionDetails( - device = "Android", - store = "Google Play Store", - platform = "Google", - platformAccount = "Google account", - subscriptionUrl = "https://play.google.com/store/account/subscriptions?package=network.loki.messenger&sku=SESSION_PRO_MONTHLY", - refundUrl = "https://getsession.org/android-refund", - ) - ), - subscriptionManager = NoOpSubscriptionManager(), sendCommand = {}, onBack = {}, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index b96c374ec4..82e904ad48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -3,15 +3,10 @@ package org.thoughtcrime.securesms.preferences.prosettings import android.annotation.SuppressLint import android.os.Parcelable import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.compose.NavHost @@ -21,13 +16,10 @@ import androidx.navigation.compose.rememberNavController import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.CancelSubscription -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.GetOrRenewPlan import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.Home import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.PlanConfirmation import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.RefundSubscription -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination.UpdatePlan -import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.GetOrRenewPlanScreen -import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.UpdatePlanScreen +import org.thoughtcrime.securesms.preferences.prosettings.chooseplan.ChoosePlanHomeScreen import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents import org.thoughtcrime.securesms.ui.UINavigator @@ -41,10 +33,7 @@ sealed interface ProSettingsDestination: Parcelable { @Serializable @Parcelize - data object UpdatePlan: ProSettingsDestination - @Serializable - @Parcelize - data object GetOrRenewPlan: ProSettingsDestination + data object ChoosePlan: ProSettingsDestination @Serializable @Parcelize @@ -117,16 +106,9 @@ fun ProSettingsNavHost( } // Subscription plan selection - horizontalSlideComposable { entry -> - val viewModel = navController.proGraphViewModel(entry, navigator) - UpdatePlanScreen( - viewModel = viewModel, - onBack = handleBack, - ) - } - horizontalSlideComposable { entry -> + horizontalSlideComposable { entry -> val viewModel = navController.proGraphViewModel(entry, navigator) - GetOrRenewPlanScreen( + ChoosePlanHomeScreen( viewModel = viewModel, onBack = handleBack, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 633f85f137..a46bf0c7c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -16,9 +16,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -72,12 +72,15 @@ class ProSettingsViewModel @AssistedInject constructor( private val _dialogState: MutableStateFlow = MutableStateFlow(DialogsState()) val dialogState: StateFlow = _dialogState - private val _choosePlanState: MutableStateFlow = MutableStateFlow(ChoosePlanState()) - val choosePlanState: StateFlow = _choosePlanState + private val _choosePlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val choosePlanState: StateFlow> = _choosePlanState private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) val refundPlanState: StateFlow = _refundPlanState + private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val cancelPlanState: StateFlow> = _cancelPlanState + init { // observe subscription status viewModelScope.launch { @@ -89,7 +92,16 @@ class ProSettingsViewModel @AssistedInject constructor( // observe purchase events viewModelScope.launch { subscriptionCoordinator.getCurrentManager().purchaseEvents.collect { purchaseEvent -> - _choosePlanState.update { it.copy(purchaseInProgress = false) } + val data = choosePlanState.value + + // stop loader + if(data is State.Success) { + _choosePlanState.update { + State.Success( + data.value.copy(purchaseInProgress = false) + ) + } + } when(purchaseEvent){ is SubscriptionManager.PurchaseEvent.Success -> { @@ -111,120 +123,6 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // Update choosePlanState whenever proSettingsUIState or the billing support change - viewModelScope.launch { - combine(_proSettingsUIState, - subscriptionCoordinator.getCurrentManager().supportsBilling - ) { proState, supportsBilling -> - val subType = proState.subscriptionState.type - val isActive = subType is SubscriptionType.Active - val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS - val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS - val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - - ChoosePlanState( - subscriptionType = subType, - hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription(), - hasBillingCapacity = supportsBilling, - enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state - plans = listOf( - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew - currentPlan = currentPlan12Months, - durationType = ProSubscriptionDuration.TWELVE_MONTHS, - badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } - - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly - .format().toString(), - selected = currentPlan3Months, - currentPlan = currentPlan3Months, - durationType = ProSubscriptionDuration.THREE_MONTHS, - badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } - - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), - ProPlan( - title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly - .format().toString(), - subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly - .format().toString(), - selected = currentPlan1Month, - currentPlan = currentPlan1Month, - durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) else emptyList(), - ), - ) - ) - } - .distinctUntilChanged() - .collect { newState -> - _choosePlanState.update { currentState -> - // Preserve the current selection if plans exist - if (currentState.plans.isNotEmpty()) { - val currentlySelectedPlan = currentState.plans.firstOrNull { it.selected } - newState.copy( - plans = newState.plans.map { plan -> - plan.copy( - selected = currentlySelectedPlan?.durationType == plan.durationType - ) - } - ) - } else { - newState - } - } - } - } - // Update refund plan state viewModelScope.launch { _proSettingsUIState.map { proUIState -> @@ -373,12 +271,8 @@ class ProSettingsViewModel @AssistedInject constructor( } } - // otherwise navigate to update or get/renew plan screen - else -> navigateTo( - if(_proSettingsUIState.value.subscriptionState.type is SubscriptionType.Active ) - ProSettingsDestination.UpdatePlan - else ProSettingsDestination.GetOrRenewPlan - ) + // go to the "choose plan" screen + else -> goToChoosePlan() } } @@ -387,7 +281,22 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToCancel -> { + // calculate state + _cancelPlanState.update { State.Loading } navigateTo(ProSettingsDestination.CancelSubscription) + + viewModelScope.launch { + val hasValidSubscription = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + _cancelPlanState.update { + State.Success( + CancelPlanState( + subscriptionType = _proSettingsUIState.value.subscriptionState.type, + hasValidSubscription = hasValidSubscription + ) + ) + } + } } Commands.GoToProSettings -> { @@ -419,13 +328,17 @@ class ProSettingsViewModel @AssistedInject constructor( } is Commands.SelectProPlan -> { - _choosePlanState.update { data -> - data.copy( - plans = data.plans.map { - it.copy(selected = it == command.plan) - }, - enableButton = data.subscriptionType !is SubscriptionType.Active.AutoRenewing - || !command.plan.currentPlan + val data: ChoosePlanState = (_choosePlanState.value as? State.Success)?.value ?: return + + _choosePlanState.update { + State.Success( + data.copy( + plans = data.plans.map { + it.copy(selected = it == command.plan) + }, + enableButton = data.subscriptionType !is SubscriptionType.Active.AutoRenewing + || !command.plan.currentPlan + ) ) } } @@ -444,9 +357,10 @@ class ProSettingsViewModel @AssistedInject constructor( Commands.GetProPlan -> { val currentSubscription = _proSettingsUIState.value.subscriptionState.type + val selectedPlan = getSelectedPlan() ?: return if(currentSubscription is SubscriptionType.Active){ - val newSubscriptionExpiryString = getSelectedPlan().durationType.expiryFromNow() + val newSubscriptionExpiryString = selectedPlan.durationType.expiryFromNow() val currentSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, @@ -456,7 +370,7 @@ class ProSettingsViewModel @AssistedInject constructor( val selectedSubscriptionDuration = DateUtils.getLocalisedTimeDuration( context = context, - amount = getSelectedPlan().durationType.duration.months, + amount = selectedPlan.durationType.duration.months, unit = MeasureUnit.MONTH ) @@ -619,19 +533,147 @@ class ProSettingsViewModel @AssistedInject constructor( //todo PRO implement properly } - private fun getSelectedPlan(): ProPlan { - return _choosePlanState.value.plans.first { it.selected } + private fun getSelectedPlan(): ProPlan? { + return (_choosePlanState.value as? State.Success)?.value?.plans?.first { it.selected } + } + + private fun goToChoosePlan(){ + // Get the choose plan state ready in loading mode + _choosePlanState.update { State.Loading } + + // Navigate to choose plan screen + navigateTo(ProSettingsDestination.ChoosePlan) + + // while the user is on the page we need to calculate the "choose plan" data + viewModelScope.launch { + val subType = _proSettingsUIState.value.subscriptionState.type + + // first check if the user has a valid subscription + val hasValidSub = subscriptionCoordinator.getCurrentManager().hasValidSubscription() + + // next get the plans, including their pricing + // there is no point in calculating it if the user is pro but without a valid sub + // (meaning they got pro from a different google account than the one they are on now + val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList() + else getSubscriptionPlans(subType) + + _choosePlanState.update { + State.Success( + ChoosePlanState( + subscriptionType = subType, + hasValidSubscription = hasValidSub, + hasBillingCapacity = subscriptionCoordinator.getCurrentManager().supportsBilling.value, + enableButton = subType !is SubscriptionType.Active.AutoRenewing, // only the auto-renew can have a disabled state + plans = plans + ) + ) + } + + /** + SHOW LOADER AT THE START OF THIS, CATER TO LOAD AND ERROR IN THE CHOOSE_HOME_SCREEN, CREATE THE STATE PROPERLY + HERE AND CALCULATE THE PLANS TO SEND AS SUCCESS + **/ + } + } + + private suspend fun getSubscriptionPlans(subType: SubscriptionType): List { + val isActive = subType is SubscriptionType.Active + val currentPlan12Months = isActive && subType.duration == ProSubscriptionDuration.TWELVE_MONTHS + val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS + val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH + + return listOf( + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) + .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) + .put(PRICE_KEY, "$47.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew + currentPlan = currentPlan12Months, + durationType = ProSubscriptionDuration.TWELVE_MONTHS, + badges = buildList { + if(currentPlan12Months){ + add( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) + } + + add( + ProPlanBadge( + "33% Off", //todo PRO calculate properly + if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, "33") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) + .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) + .put(PRICE_KEY, "$14.99") //todo PRO calculate properly + .format().toString(), + selected = currentPlan3Months, + currentPlan = currentPlan3Months, + durationType = ProSubscriptionDuration.THREE_MONTHS, + badges = buildList { + if(currentPlan3Months){ + add( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) + } + + add( + ProPlanBadge( + "16% Off", //todo PRO calculate properly + if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, "16") //todo PRO calculate properly + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + ) + ) + }, + ), + ProPlan( + title = Phrase.from(context.getText(R.string.proPriceOneMonth)) + .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly + .format().toString(), + subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) + .put(PRICE_KEY, "$5") //todo PRO calculate properly + .format().toString(), + selected = currentPlan1Month, + currentPlan = currentPlan1Month, + durationType = ProSubscriptionDuration.ONE_MONTH, + badges = if(currentPlan1Month) listOf( + ProPlanBadge(context.getString(R.string.currentBilling)) + ) else emptyList(), + ), + ) } private fun getPlanFromProvider(){ viewModelScope.launch { + val selectedPlan = getSelectedPlan() ?: return@launch + val purchaseStarted = subscriptionCoordinator.getCurrentManager().purchasePlan( - getSelectedPlan().durationType + selectedPlan.durationType ) - if(purchaseStarted.isSuccess) { + val data = choosePlanState.value + if(purchaseStarted.isSuccess && data is State.Success) { _choosePlanState.update { - it.copy(purchaseInProgress = true) + State.Success( + data.value.copy(purchaseInProgress = true) + ) } } } @@ -682,13 +724,18 @@ class ProSettingsViewModel @AssistedInject constructor( data class ChoosePlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasBillingCapacity: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession - val hasValidSubscription: Boolean = false, + val hasBillingCapacity: Boolean = false, + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession val purchaseInProgress: Boolean = false, val plans: List = emptyList(), val enableButton: Boolean = false, ) + data class CancelPlanState( + val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, + val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + ) + data class RefundPlanState( val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, val isQuickRefund: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt similarity index 100% rename from app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanScreen.kt rename to app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlan.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt new file mode 100644 index 0000000000..5085c6003d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.preferences.prosettings.chooseplan + +import android.widget.Toast +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import network.loki.messenger.R +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel +import org.thoughtcrime.securesms.pro.SubscriptionType +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.util.State + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ChoosePlanHomeScreen( + viewModel: ProSettingsViewModel, + onBack: () -> Unit, +) { + val state by viewModel.choosePlanState.collectAsState() + + when(state){ + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } + + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is State.Success -> { + val planData = (state as State.Success).value + + // Option 1. ACTIVE Pro subscription + if(planData.subscriptionType is SubscriptionType.Active) { + val subscription = planData.subscriptionType + + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + // or we have no billing APIs + subscription.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription + || !planData.hasBillingCapacity -> + ChoosePlanNonOriginating( + subscription = planData.subscriptionType, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } else { // Option 2. Get brand new or Renew plan + when { + // there are no billing options on this device + !planData.hasBillingCapacity -> + ChoosePlanNoBilling( + subscription = planData.subscriptionType, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt deleted file mode 100644 index 0e662d612d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/GetOrRenewPlanScreen.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.thoughtcrime.securesms.preferences.prosettings.chooseplan - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel -import org.thoughtcrime.securesms.pro.SubscriptionType - - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun GetOrRenewPlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - // Renew plan - val planData by viewModel.choosePlanState.collectAsState() - - when { - // there are no billing options on this device - !planData.hasBillingCapacity -> - ChoosePlanNoBilling( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt deleted file mode 100644 index 6ccfd0edee..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/UpdatePlanScreen.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.thoughtcrime.securesms.preferences.prosettings.chooseplan - -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel -import org.thoughtcrime.securesms.pro.SubscriptionType - - -@OptIn(ExperimentalSharedTransitionApi::class) -@Composable -fun UpdatePlanScreen( - viewModel: ProSettingsViewModel, - onBack: () -> Unit, -) { - // Update plan - val planData by viewModel.choosePlanState.collectAsState() - val subscription = planData.subscriptionType as? SubscriptionType.Active - // can't update a plan if the subscription isn't currently active - if(subscription == null){ - onBack() - return - } - - val subscriptionManager = viewModel.getSubscriptionManager() - - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - // or we have no billing APIs - subscription.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription - || !subscriptionManager.supportsBilling.value -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType as SubscriptionType.Active, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - } -} - - From 760a69364112f9f6ba4d46d9258680b8ea0a9695 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 10:59:02 +1100 Subject: [PATCH 099/219] New debug toggle for quick refunds --- .../utilities/TextSecurePreferences.kt | 12 +++++++ .../securesms/debugmenu/DebugMenu.kt | 10 ++++++ .../securesms/debugmenu/DebugMenuViewModel.kt | 10 ++++++ .../prosettings/ProSettingsViewModel.kt | 32 +++++++++---------- .../PlayStoreSubscriptionManager.kt | 2 ++ 5 files changed, 49 insertions(+), 17 deletions(-) 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 d0e556103f..b54c76334c 100644 --- a/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/app/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -222,6 +222,8 @@ interface TextSecurePreferences { fun setDebugProPlanStatus(status: DebugMenuViewModel.DebugProPlanStatus?) fun getDebugForceNoBilling(): Boolean fun setDebugForceNoBilling(hasBilling: Boolean) + fun getDebugIsWithinQuickRefund(): Boolean + fun setDebugIsWithinQuickRefund(isWithin: Boolean) fun setSubscriptionProvider(provider: String) fun getSubscriptionProvider(): String? @@ -388,6 +390,7 @@ interface TextSecurePreferences { const val DEBUG_SUBSCRIPTION_STATUS = "debug_subscription_status" 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" const val SUBSCRIPTION_PROVIDER = "session_subscription_provider" const val DEBUG_AVATAR_REUPLOAD = "debug_avatar_reupload" @@ -1799,6 +1802,15 @@ class AppTextSecurePreferences @Inject constructor( _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) } + override fun getDebugIsWithinQuickRefund(): Boolean { + return getBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, false) + } + + override fun setDebugIsWithinQuickRefund(isWithin: Boolean) { + setBooleanPreference(TextSecurePreferences.DEBUG_WITHIN_QUICK_REFUND, isWithin) + _events.tryEmit(TextSecurePreferences.DEBUG_FORCE_NO_BILLING) + } + override fun getSubscriptionProvider(): String? { return getStringPreference(TextSecurePreferences.SUBSCRIPTION_PROVIDER, null) } 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 2d4ff746d8..4db3947687 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -278,6 +278,15 @@ fun DebugMenu( ) } ) + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + DebugSwitchRow( + text = "Is Within Quick Refund Window", + checked = uiState.withinQuickRefund, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.WithinQuickRefund(it)) + } + ) } } @@ -831,6 +840,7 @@ fun PreviewDebugMenu() { selectedDebugProPlanStatus = DebugMenuViewModel.DebugProPlanStatus.NORMAL, debugProPlans = emptyList(), forceNoBilling = false, + withinQuickRefund = true, forceDeterministicEncryption = false, debugAvatarReupload = true, ), 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 bd7b13db69..fb2b6786cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -110,6 +110,7 @@ class DebugMenuViewModel @Inject constructor( .flatMap { it.availablePlans.asSequence().map { plan -> DebugProPlan(it, plan) } } .toList(), forceNoBilling = textSecurePreferences.getDebugForceNoBilling(), + withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, ) @@ -285,6 +286,13 @@ class DebugMenuViewModel @Inject constructor( } } + is Commands.WithinQuickRefund -> { + textSecurePreferences.setDebugIsWithinQuickRefund(command.set) + _uiState.update { + it.copy(withinQuickRefund = command.set) + } + } + is Commands.ForcePostPro -> { textSecurePreferences.setForcePostPro(command.set) _uiState.update { @@ -471,6 +479,7 @@ class DebugMenuViewModel @Inject constructor( val selectedDebugProPlanStatus: DebugProPlanStatus, val debugProPlans: List, val forceNoBilling: Boolean, + val withinQuickRefund: Boolean, val alternativeFileServer: FileServer? = null, val availableAltFileServers: List = emptyList(), ) @@ -511,6 +520,7 @@ class DebugMenuViewModel @Inject constructor( data class ForceOtherUsersAsPro(val set: Boolean) : Commands() 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: ProStatusManager.MessageProFeature, val set: Boolean) : Commands() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index a46bf0c7c7..956e69ca6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -36,6 +36,7 @@ import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_KEY import org.session.libsession.utilities.StringSubstitutionConstants.SELECTED_PLAN_LENGTH_SINGULAR_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_KEY +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.ShowOpenUrlDialog import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionState @@ -82,6 +83,7 @@ class ProSettingsViewModel @AssistedInject constructor( val cancelPlanState: StateFlow> = _cancelPlanState init { + Log.w("", "*** VM INIT") // observe subscription status viewModelScope.launch { proStatusManager.subscriptionState.collect { @@ -122,22 +124,6 @@ class ProSettingsViewModel @AssistedInject constructor( } } } - - // Update refund plan state - viewModelScope.launch { - _proSettingsUIState.map { proUIState -> - val subManager = subscriptionCoordinator.getCurrentManager() - RefundPlanState( - subscriptionType = proUIState.subscriptionState.type, - isQuickRefund = subManager.isWithinQuickRefundWindow(), - quickRefundUrl = subManager.quickRefundUrl - ) - } - .distinctUntilChanged() - .collect { - _refundPlanState.update { it } - } - } } private fun generateState(subscriptionState: SubscriptionState){ @@ -277,7 +263,19 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToRefund -> { - navigateTo(ProSettingsDestination.RefundSubscription) + viewModelScope.launch { + val subManager = subscriptionCoordinator.getCurrentManager() + _refundPlanState.update { + RefundPlanState( + subscriptionType = _proSettingsUIState.value.subscriptionState.type, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) + } + + //todo PRO might need a State here as well... + navigateTo(ProSettingsDestination.RefundSubscription) + } } Commands.GoToCancel -> { diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 2aa8b8d689..4056eb883b 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -258,6 +258,8 @@ class PlayStoreSubscriptionManager @Inject constructor( } override suspend fun isWithinQuickRefundWindow(): Boolean { + if(prefs.getDebugIsWithinQuickRefund()) return true // debug mode + val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false val now = Instant.now() From 63c3d0c4a068c6f3099f977f6bc092875cf9af0c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 11:14:16 +1100 Subject: [PATCH 100/219] Making sure refund also handle its data within a State --- .../prosettings/BaseStateProScreen.kt | 47 +++++++++ .../prosettings/CancelPlanScreen.kt | 57 ++++------- .../prosettings/ProSettingsViewModel.kt | 41 ++++---- .../prosettings/RefundPlanScreen.kt | 44 +++++---- .../chooseplan/ChoosePlanHomeScreen.kt | 98 +++++++------------ 5 files changed, 149 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt new file mode 100644 index 0000000000..f5a8229612 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -0,0 +1,47 @@ +package org.thoughtcrime.securesms.preferences.prosettings + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import network.loki.messenger.R +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator +import org.thoughtcrime.securesms.util.State + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BaseStateProScreen( + state: State, + onBack: () -> Unit, + successContent: @Composable (T) -> Unit +) { + when (state) { + is State.Error -> { + // show a toast and go back to pro settings home screen + Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } + + is State.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + ) { + BackAppBar(title = "", onBack = onBack) + + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + + is State.Success -> successContent(state.value) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt index b826dfd584..fa3c9ba6a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/CancelPlanScreen.kt @@ -45,48 +45,29 @@ fun CancelPlanScreen( ) { val state by viewModel.cancelPlanState.collectAsState() - when(state) { - is State.Error -> { - // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } - - is State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is State.Success -> { - val planData = (state as State.Success).value - val activePlan = planData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + BaseStateProScreen( + state = state, + onBack = onBack + ){ planData -> + val activePlan = planData.subscriptionType - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - activePlan.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription -> - CancelPlanNonOriginating( - subscriptionDetails = activePlan.subscriptionDetails, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default cancel screen - else -> CancelPlan( + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + activePlan.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription -> + CancelPlanNonOriginating( + subscriptionDetails = activePlan.subscriptionDetails, sendCommand = viewModel::onCommand, onBack = onBack, ) - } + + // default cancel screen + else -> CancelPlan( + sendCommand = viewModel::onCommand, + onBack = onBack, + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 956e69ca6b..484f944a2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -16,11 +16,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.R @@ -76,8 +73,8 @@ class ProSettingsViewModel @AssistedInject constructor( private val _choosePlanState: MutableStateFlow> = MutableStateFlow(State.Loading) val choosePlanState: StateFlow> = _choosePlanState - private val _refundPlanState: MutableStateFlow = MutableStateFlow(RefundPlanState()) - val refundPlanState: StateFlow = _refundPlanState + private val _refundPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) + val refundPlanState: StateFlow> = _refundPlanState private val _cancelPlanState: MutableStateFlow> = MutableStateFlow(State.Loading) val cancelPlanState: StateFlow> = _cancelPlanState @@ -263,22 +260,30 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GoToRefund -> { + val sub = _proSettingsUIState.value.subscriptionState.type + if(sub !is SubscriptionType.Active) return + + _refundPlanState.update { State.Loading } + navigateTo(ProSettingsDestination.RefundSubscription) + viewModelScope.launch { val subManager = subscriptionCoordinator.getCurrentManager() _refundPlanState.update { - RefundPlanState( - subscriptionType = _proSettingsUIState.value.subscriptionState.type, - isQuickRefund = subManager.isWithinQuickRefundWindow(), - quickRefundUrl = subManager.quickRefundUrl + State.Success( + RefundPlanState( + subscriptionType = sub, + isQuickRefund = subManager.isWithinQuickRefundWindow(), + quickRefundUrl = subManager.quickRefundUrl + ) ) } - - //todo PRO might need a State here as well... - navigateTo(ProSettingsDestination.RefundSubscription) } } Commands.GoToCancel -> { + val sub = _proSettingsUIState.value.subscriptionState.type + if(sub !is SubscriptionType.Active) return + // calculate state _cancelPlanState.update { State.Loading } navigateTo(ProSettingsDestination.CancelSubscription) @@ -289,7 +294,7 @@ class ProSettingsViewModel @AssistedInject constructor( _cancelPlanState.update { State.Success( CancelPlanState( - subscriptionType = _proSettingsUIState.value.subscriptionState.type, + subscriptionType = sub, hasValidSubscription = hasValidSubscription ) ) @@ -730,14 +735,14 @@ class ProSettingsViewModel @AssistedInject constructor( ) data class CancelPlanState( - val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val hasValidSubscription: Boolean = false, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession + val subscriptionType: SubscriptionType.Active, + val hasValidSubscription: Boolean, // true is there is a current subscription AND the available subscription manager on this device has an account which matches the product id we got from libsession ) data class RefundPlanState( - val subscriptionType: SubscriptionType = SubscriptionType.NeverSubscribed, - val isQuickRefund: Boolean = false, - val quickRefundUrl: String? = null + val subscriptionType: SubscriptionType.Active, + val isQuickRefund: Boolean, + val quickRefundUrl: String? ) data class ProStats( 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 e964f36a65..376c41d3ec 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 @@ -44,31 +44,33 @@ fun RefundPlanScreen( viewModel: ProSettingsViewModel, onBack: () -> Unit, ) { - val refundData by viewModel.refundPlanState.collectAsState() - val activePlan = refundData.subscriptionType as? SubscriptionType.Active - if (activePlan == null) { - onBack() - return - } + val state by viewModel.refundPlanState.collectAsState() + + BaseStateProScreen( + state = state, + onBack = onBack + ) { refundData -> + val activePlan = refundData.subscriptionType + + // there are different UI depending on the state + when { + // there is an active subscription but from a different platform + activePlan.subscriptionDetails.isFromAnotherPlatform() -> + RefundPlanNonOriginating( + subscription = activePlan, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) - // there are different UI depending on the state - when { - // there is an active subscription but from a different platform - activePlan.subscriptionDetails.isFromAnotherPlatform() -> - RefundPlanNonOriginating( - subscription = activePlan, + // default refund screen + else -> RefundPlan( + data = activePlan, + isQuickRefund = refundData.isQuickRefund, + quickRefundUrl = refundData.quickRefundUrl, sendCommand = viewModel::onCommand, onBack = onBack, ) - - // default refund screen - else -> RefundPlan( - data = activePlan, - isQuickRefund = refundData.isQuickRefund, - quickRefundUrl = refundData.quickRefundUrl, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt index 5085c6003d..2e5a289666 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/chooseplan/ChoosePlanHomeScreen.kt @@ -1,20 +1,12 @@ package org.thoughtcrime.securesms.preferences.prosettings.chooseplan -import android.widget.Toast import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import network.loki.messenger.R +import org.thoughtcrime.securesms.preferences.prosettings.BaseStateProScreen import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.pro.SubscriptionType -import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator -import org.thoughtcrime.securesms.util.State @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -24,66 +16,50 @@ fun ChoosePlanHomeScreen( ) { val state by viewModel.choosePlanState.collectAsState() - when(state){ - is State.Error -> { - // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } - - is State.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } - - is State.Success -> { - val planData = (state as State.Success).value + BaseStateProScreen( + state = state, + onBack = onBack + ) { planData -> + // Option 1. ACTIVE Pro subscription + if(planData.subscriptionType is SubscriptionType.Active) { + val subscription = planData.subscriptionType - // Option 1. ACTIVE Pro subscription - if(planData.subscriptionType is SubscriptionType.Active) { - val subscription = planData.subscriptionType - - when { - // there is an active subscription but from a different platform or from the - // same platform but a different account - // or we have no billing APIs - subscription.subscriptionDetails.isFromAnotherPlatform() - || !planData.hasValidSubscription - || !planData.hasBillingCapacity -> - ChoosePlanNonOriginating( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - - // default plan chooser - else -> ChoosePlan( - planData = planData, + when { + // there is an active subscription but from a different platform or from the + // same platform but a different account + // or we have no billing APIs + subscription.subscriptionDetails.isFromAnotherPlatform() + || !planData.hasValidSubscription + || !planData.hasBillingCapacity -> + ChoosePlanNonOriginating( + subscription = planData.subscriptionType, sendCommand = viewModel::onCommand, onBack = onBack, ) - } - } else { // Option 2. Get brand new or Renew plan - when { - // there are no billing options on this device - !planData.hasBillingCapacity -> - ChoosePlanNoBilling( - subscription = planData.subscriptionType, - sendCommand = viewModel::onCommand, - onBack = onBack, - ) - // default plan chooser - else -> ChoosePlan( - planData = planData, + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) + } + } else { // Option 2. Get brand new or Renew plan + when { + // there are no billing options on this device + !planData.hasBillingCapacity -> + ChoosePlanNoBilling( + subscription = planData.subscriptionType, sendCommand = viewModel::onCommand, onBack = onBack, ) - } + + // default plan chooser + else -> ChoosePlan( + planData = planData, + sendCommand = viewModel::onCommand, + onBack = onBack, + ) } } } From e6134d72303e6c3ecce3a317238f521cf186f879 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 13:50:34 +1100 Subject: [PATCH 101/219] Adding price calculation and formatting --- .../prosettings/ProSettingsViewModel.kt | 122 +++++++++++------- .../subscription/NoOpSubscriptionManager.kt | 4 + .../pro/subscription/SubscriptionManager.kt | 12 +- .../securesms/util/CurrencyFormatter.kt | 54 ++++++++ .../PlayStoreSubscriptionManager.kt | 44 ++++++- 5 files changed, 184 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 484f944a2a..ccff4d397f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -45,8 +45,10 @@ import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.pro.subscription.expiryFromNow import org.thoughtcrime.securesms.ui.SimpleDialogData import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.util.CurrencyFormatter import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.State +import java.math.BigDecimal @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @@ -558,7 +560,16 @@ class ProSettingsViewModel @AssistedInject constructor( // there is no point in calculating it if the user is pro but without a valid sub // (meaning they got pro from a different google account than the one they are on now val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList() - else getSubscriptionPlans(subType) + else { + // attempt to get the prices from the subscription provider + // return early in case of error + try { + getSubscriptionPlans(subType) + } catch (e: Exception){ + _choosePlanState.update { State.Error(e) } + return@launch + } + } _choosePlanState.update { State.Success( @@ -585,81 +596,96 @@ class ProSettingsViewModel @AssistedInject constructor( val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH - return listOf( + // get prices from the subscription provider + val prices = subscriptionCoordinator.getCurrentManager().getSubscriptionPrices() + + val data1Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.ONE_MONTH }) + val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS }) + val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS }) + + val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO + + val plan12Months = data12Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceTwelveMonths)) - .put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledAnnually)) - .put(PRICE_KEY, "$47.99") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew currentPlan = currentPlan12Months, durationType = ProSubscriptionDuration.TWELVE_MONTHS, badges = buildList { - if(currentPlan12Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } + if (currentPlan12Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan12Months)?.let(this::add) + } + ) + } - add( - ProPlanBadge( - "33% Off", //todo PRO calculate properly - if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "33") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), + val plan3Months = data3Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceThreeMonths)) - .put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly)) - .put(PRICE_KEY, "$14.99") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan3Months, currentPlan = currentPlan3Months, durationType = ProSubscriptionDuration.THREE_MONTHS, badges = buildList { - if(currentPlan3Months){ - add( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) - } + if (currentPlan3Months) add(ProPlanBadge(context.getString(R.string.currentBilling))) + discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan3Months)?.let(this::add) + } + ) + } - add( - ProPlanBadge( - "16% Off", //todo PRO calculate properly - if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip)) - .put(PRO_KEY, NonTranslatableStringConstants.PRO) - .put(PERCENT_KEY, "16") //todo PRO calculate properly - .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) - .format().toString() - else null - ) - ) - }, - ), + val plan1Month = data1Month?.let { ProPlan( title = Phrase.from(context.getText(R.string.proPriceOneMonth)) - .put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly + .put(MONTHLY_PRICE_KEY, it.perMonthText) .format().toString(), subtitle = Phrase.from(context.getText(R.string.proBilledMonthly)) - .put(PRICE_KEY, "$5") //todo PRO calculate properly + .put(PRICE_KEY, it.totalText) .format().toString(), selected = currentPlan1Month, currentPlan = currentPlan1Month, durationType = ProSubscriptionDuration.ONE_MONTH, - badges = if(currentPlan1Month) listOf( - ProPlanBadge(context.getString(R.string.currentBilling)) - ) else emptyList(), - ), + badges = if (currentPlan1Month) listOf(ProPlanBadge(context.getString(R.string.currentBilling))) else emptyList() + // no discount on the baseline 1 month... + ) + } + + return listOfNotNull(plan12Months, plan3Months, plan1Month) + } + + private data class PriceDisplayData(val perMonthUnits: BigDecimal, val perMonthText: String, val totalText: String) + + private fun calculatePricesFor(pricing: SubscriptionManager.SubscriptionPricing?): PriceDisplayData? { + if(pricing == null) return null + + val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso) + val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode) + val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode) + return PriceDisplayData(perMonthUnits, perMonthText, pricing.formattedTotal) + } + + private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? { + val pct = CurrencyFormatter.percentOffFloor(baseline, perMonthUnits) + if (pct <= 0) return null + val tooltip = if (showTooltip) + Phrase.from(context.getText(R.string.proDiscountTooltip)) + .put(PRO_KEY, NonTranslatableStringConstants.PRO) + .put(PERCENT_KEY, pct.toString()) + .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) + .format().toString() + else null + return ProPlanBadge( + title = Phrase.from(context.getText(R.string.proPercentOff)) + .put(PERCENT_KEY, pct.toString()) + .format().toString(), + tooltip = tooltip ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 9d1e64a615..05375437ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -36,4 +36,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override suspend fun isWithinQuickRefundWindow(): Boolean { return false } + + override suspend fun getSubscriptionPrices(): List { + return emptyList() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index e44d6691d5..4606856cfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -45,9 +45,19 @@ interface SubscriptionManager: OnAppStartupComponent { */ suspend fun hasValidSubscription(): Boolean + /** + * Gets a list of pricing for the subscriptions + * @throws Exception in case of errors fetching prices + */ + @Throws(Exception::class) + suspend fun getSubscriptionPrices(): List + data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, - val price: String, + val priceAmountMicros: Long, + val priceCurrencyCode: String, + val billingPeriodIso: String, + val formattedTotal: String, ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt new file mode 100644 index 0000000000..1373221711 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +/** + * Utility for converting and formatting prices + * to correctly localized strings. + * + * - Only supports months/years for ISO 8601 billing periods (PXM, PXY, P1Y6M) - We can add more if needed in the future + */ +object CurrencyFormatter { + + /** Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) */ + fun monthsFromIso(iso: String): Int { + val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 + return (y * 12 + m).coerceAtLeast(1) + } + + /** Currency fraction digits with sane default. */ + private fun fractionDigits(code: String): Int = + Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 } + + /** PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. */ + fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal { + val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // raw units + val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN) + return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN) + } + + /** Locale-correct currency formatting (no extra rounding — use the scale already on amount). */ + fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String { + val nf = NumberFormat.getCurrencyInstance(locale) + nf.currency = Currency.getInstance(currencyCode) + return nf.format(amountUnits) + } + + /** + * Used to calculate discounts: + * floor(((baseline - plan)/baseline) * 100). Assumes both inputs already floored to fraction. + **/ + fun percentOffFloor(baselinePerMonthUnits: BigDecimal, planPerMonthUnits: BigDecimal): Int { + if (baselinePerMonthUnits <= BigDecimal.ZERO || planPerMonthUnits >= baselinePerMonthUnits) return 0 + val pct = baselinePerMonthUnits.subtract(planPerMonthUnits) + .divide(baselinePerMonthUnits, 6, RoundingMode.DOWN) + .multiply(BigDecimal(100)) + .setScale(0, RoundingMode.DOWN) + return pct.toInt() + } +} \ No newline at end of file diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 4056eb883b..cc2ca99fea 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.pro.subscription import android.app.Application import android.widget.Toast -import androidx.compose.ui.res.stringResource import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams @@ -18,7 +17,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -44,7 +42,6 @@ import java.time.Instant import java.time.temporal.ChronoUnit import javax.inject.Inject import javax.inject.Singleton -import kotlin.time.measureTime /** * The Google Play Store implementation of our subscription manager @@ -272,6 +269,47 @@ class PlayStoreSubscriptionManager @Inject constructor( return now.isBefore(refundDeadline) } + @Throws(Exception::class) + override suspend fun getSubscriptionPrices(): List { + val result = getProductDetails() + check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + "Failed to query product details. Reason: ${result.billingResult}" + } + + val productDetails = result.productDetailsList?.firstOrNull() + ?: run { + Log.w(TAG, "No ProductDetails returned for product id session_pro") + return emptyList() + } + + val offersByBasePlan = productDetails.subscriptionOfferDetails + ?.associateBy { it.basePlanId } + .orEmpty() + + // For each duration we support, find the matching offer by basePlanId + return availablePlans.mapNotNull { duration -> + val offer = offersByBasePlan[duration.id] + if (offer == null) { + Log.w(TAG, "No offer found for basePlanId=${duration.id}") + return@mapNotNull null + } + + val phases = offer.pricingPhases.pricingPhaseList + + val pricing = phases.firstOrNull { + it.recurrenceMode == com.android.billingclient.api.ProductDetails.RecurrenceMode.INFINITE_RECURRING + } ?:return@mapNotNull null // skip if not found + + SubscriptionManager.SubscriptionPricing( + subscriptionDuration = duration, + priceAmountMicros = pricing.priceAmountMicros, + priceCurrencyCode = pricing.priceCurrencyCode, + billingPeriodIso = pricing.billingPeriod, // e.g., P1M, P3M, P1Y + formattedTotal = pricing.formattedPrice // Play-formatted localized total + ) + } + } + companion object { private const val TAG = "PlayStoreSubscriptionManager" } From 556cbd18324fdd0f2d5bd2de1408cd83e82c6e28 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 15:33:23 +1100 Subject: [PATCH 102/219] Formatting total to match --- .../prosettings/ProSettingsViewModel.kt | 9 +++++- .../securesms/util/CurrencyFormatter.kt | 29 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index ccff4d397f..7f35456a45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -668,7 +668,14 @@ class ProSettingsViewModel @AssistedInject constructor( val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso) val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode) val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode) - return PriceDisplayData(perMonthUnits, perMonthText, pricing.formattedTotal) + + val totalUnits = CurrencyFormatter.microToBigDecimal(pricing.priceAmountMicros) + val totalText = CurrencyFormatter.formatUnits( + amountUnits = totalUnits, + currencyCode = pricing.priceCurrencyCode + ) + + return PriceDisplayData(perMonthUnits, perMonthText, totalText) } private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt index 1373221711..6aeacb40e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CurrencyFormatter.kt @@ -14,25 +14,44 @@ import java.util.Locale */ object CurrencyFormatter { - /** Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) */ + /** + * Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) + **/ fun monthsFromIso(iso: String): Int { val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0 return (y * 12 + m).coerceAtLeast(1) } - /** Currency fraction digits with sane default. */ + /** + * Currency fraction digits (e.g., USD=2, JPY=0). Default to 2 if unknown. + **/ private fun fractionDigits(code: String): Int = Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 } - /** PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. */ + /** + * PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. + **/ fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal { - val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // raw units + // 1) Convert Play’s micros → currency *units* as a BigDecimal + val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // e.g., 47_99_0000 → 47.99 + + // 2) Compute the raw monthly price: total / months. + // We keep extra precision (scale=10) and ROUND DOWN to avoid accidental rounding up mid-way. val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN) + + // 3) Floor to the currency’s smallest unit (fraction digits): + // USD/EUR/AUD → 2 decimals, JPY/KRW → 0 decimals, KWD → 3 decimals, etc. return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN) } - /** Locale-correct currency formatting (no extra rounding — use the scale already on amount). */ + fun microToBigDecimal(micro: Long): BigDecimal { + return BigDecimal(micro).divide(BigDecimal(1_000_000)) + } + + /** + * Locale-correct currency formatting + **/ fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String { val nf = NumberFormat.getCurrencyInstance(locale) nf.currency = Currency.getInstance(currencyCode) From 8d9e34f1600c83cb9a54b983717c9006d7ef192b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 16:09:21 +1100 Subject: [PATCH 103/219] Do not apply debug setting if we are not forcing the user as pro --- .../securesms/pro/subscription/PlayStoreSubscriptionManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index cc2ca99fea..106a65a4f9 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -255,7 +255,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } override suspend fun isWithinQuickRefundWindow(): Boolean { - if(prefs.getDebugIsWithinQuickRefund()) return true // debug mode + if(prefs.getDebugIsWithinQuickRefund() && prefs.forceCurrentUserAsPro()) return true // debug mode val purchaseTimeMillis = getExistingSubscription()?.purchaseTime ?: return false From 85ada7d2d65b6c85add9c7d091a56c5369c35fd2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 31 Oct 2025 16:14:37 +1100 Subject: [PATCH 104/219] Fixing old component to use crossfade for better transition --- .../org/thoughtcrime/securesms/ui/Components.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 2ad98b9ace..9183d20c93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutLinearInEasing @@ -639,11 +640,15 @@ fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { @Composable fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { - AnimatedVisibility(loading) { - SmallCircularProgressIndicator(color = LocalContentColor.current) - } - AnimatedVisibility(!loading) { - content() + Crossfade(loading) { isLoading -> + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + if (isLoading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } else { + content() + } + } + } } From bb5fc1d8ee2a76f3ae159686fe2ebc5b25534c97 Mon Sep 17 00:00:00 2001 From: jbsession Date: Mon, 3 Nov 2025 07:22:53 +0800 Subject: [PATCH 105/219] SES-4753 - New message sheet string changes (#1652) * New message description icon change * Ons lookup failed error * unregistered ONS error string * Modal when clicking help * Invalid account id check * Updated url dialog commands --- .../session/libsignal/utilities/Validation.kt | 20 ++++++- .../StartConversationSheet.kt | 25 ++++++--- .../newmessage/NewMessage.kt | 2 +- .../newmessage/NewMessageViewModel.kt | 53 ++++++++++++++++--- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/session/libsignal/utilities/Validation.kt b/app/src/main/java/org/session/libsignal/utilities/Validation.kt index fdf9bd386f..eaa1fa1bab 100644 --- a/app/src/main/java/org/session/libsignal/utilities/Validation.kt +++ b/app/src/main/java/org/session/libsignal/utilities/Validation.kt @@ -4,8 +4,24 @@ object PublicKeyValidation { private val HEX_CHARACTERS = "0123456789ABCDEFabcdef".toSet() private val INVALID_PREFIXES = setOf(IdPrefix.GROUP, IdPrefix.BLINDED, IdPrefix.BLINDEDV2) - fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean = hasValidLength(candidate) && isValidHexEncoding(candidate) && (!isPrefixRequired || IdPrefix.fromValue(candidate) != null) + fun isValid(candidate: String, isPrefixRequired: Boolean = true): Boolean { + if (!hasValidLength(candidate)) return false + + val prefix = IdPrefix.fromValue(candidate) + + // Handle invalid Account ID conditions + // Case 1: Standard prefix "05" but not valid hex + if (prefix == IdPrefix.STANDARD && !isValidHexEncoding(candidate)) return false + + // Case 2: Blinded or Group IDs should never be accepted as valid Account IDs + if (prefix in INVALID_PREFIXES) return false + + // Standard validity rules + return isValidHexEncoding(candidate) && + (!isPrefixRequired || prefix != null) + } + fun hasValidPrefix(candidate: String) = IdPrefix.fromValue(candidate) !in INVALID_PREFIXES - private fun hasValidLength(candidate: String) = candidate.length == 66 + fun hasValidLength(candidate: String) = candidate.length == 66 private fun isValidHexEncoding(candidate: String) = HEX_CHARACTERS.containsAll(candidate.toSet()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index fc3652f386..9ce71dfba7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -16,8 +16,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -39,6 +41,7 @@ import org.thoughtcrime.securesms.home.startconversation.newmessage.State import org.thoughtcrime.securesms.openUrl import org.thoughtcrime.securesms.ui.NavigationAction import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.ui.components.BaseBottomSheet import org.thoughtcrime.securesms.ui.horizontalSlideComposable @@ -152,13 +155,17 @@ fun StartConversationNavHost( val viewModel = hiltViewModel() val uiState by viewModel.state.collectAsState(State()) + val helpUrl = "https://getsession.org/account-ids" + LaunchedEffect(Unit) { scope.launch { viewModel.success.collect { - context.startActivity(ConversationActivityV2.createIntent( - context, - address = it.address - )) + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address + ) + ) onClose() } @@ -169,10 +176,16 @@ fun StartConversationNavHost( uiState, viewModel.qrErrors, viewModel, - onBack = { scope.launch { navigator.navigateUp() }}, + onBack = { scope.launch { navigator.navigateUp() } }, onClose = onClose, - onHelp = { activity?.openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") } + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } ) + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } + ) + } } // Create Group diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt index cf0cff108b..8981f6c99c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessage.kt @@ -124,7 +124,7 @@ private fun EnterAccountId( .fillMaxWidth(), style = LocalType.current.small, color = LocalColors.current.textSecondary, - iconRes = R.drawable.ic_circle_help, + iconRes = R.drawable.ic_square_arrow_up_right, onClick = onHelp ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt index 1402f7ac56..d76cdcf457 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/newmessage/NewMessageViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.phrase.Phrase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow @@ -19,9 +20,11 @@ import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.upsertContact import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.PublicKeyValidation +import org.thoughtcrime.securesms.preferences.SettingsViewModel import org.thoughtcrime.securesms.ui.GetString import java.net.IDN import javax.inject.Inject @@ -69,10 +72,20 @@ class NewMessageViewModel @Inject constructor( } } - if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { - onUnvalidatedPublicKey(publicKey = idOrONS) + if (PublicKeyValidation.hasValidLength(idOrONS)) { + if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) { + onUnvalidatedPublicKey(idOrONS) + } else { + _state.update { + it.copy( + isTextErrorColor = true, + error = GetString(R.string.accountIdErrorInvalid), + loading = false + ) + } + } } else { - resolveONS(ons = idOrONS) + resolveONS(idOrONS) } } @@ -122,7 +135,6 @@ class NewMessageViewModel @Inject constructor( if (address is Address.Standard) { viewModelScope.launch { _success.emit(Success(address)) } } - } private fun onUnvalidatedPublicKey(publicKey: String) { @@ -134,8 +146,31 @@ class NewMessageViewModel @Inject constructor( } private fun Exception.toMessage() = when (this) { - is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized) - else -> application.getString(R.string.onsErrorUnableToSearch) + is SnodeAPI.Error.Generic -> application.getString(R.string.errorUnregisteredOns) + else -> Phrase.from(application, R.string.errorNoLookupOns) + .put(APP_NAME_KEY, application.getString(R.string.app_name)) + .format().toString() + } + + fun onCommand(commands: Commands) { + when (commands) { + is Commands.ShowUrlDialog -> { + _state.update { it.copy(showUrlDialog = true) } + } + + is Commands.DismissUrlDialog -> { + _state.update { + it.copy( + showUrlDialog = false + ) + } + } + } + } + + sealed interface Commands { + data object ShowUrlDialog : Commands + data object DismissUrlDialog : Commands } } @@ -143,9 +178,13 @@ data class State( val newMessageIdOrOns: String = "", val isTextErrorColor: Boolean = false, val error: GetString? = null, - val loading: Boolean = false + val loading: Boolean = false, + val showUrlDialog : Boolean = false ) { val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank() } + + + data class Success(val address: Address.Standard) \ No newline at end of file From 0e3a987a8708d8f431259435d0d644ca3e3c96d2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 3 Nov 2025 11:07:20 +1100 Subject: [PATCH 106/219] Removing suffix for QA + PR feedback --- app/build.gradle.kts | 3 +-- .../preferences/prosettings/BaseStateProScreen.kt | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 874d9d152f..7ef76a3438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,13 +181,12 @@ android { matchingFallbacks += "release" signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".$name" devNetDefaultOn(false) enablePermissiveNetworkSecurityConfig(true) setAlternativeAppName("Session QA") - setAuthorityPostfix(".qa") + setAuthorityPostfix("") } create("automaticQa") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt index f5a8229612..4e6d5ff690 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,8 +27,12 @@ fun BaseStateProScreen( when (state) { is State.Error -> { // show a toast and go back to pro settings home screen - Toast.makeText(LocalContext.current, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() + val context = LocalContext.current + + LaunchedEffect(Unit) { + Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() + } } is State.Loading -> { From 2e77128aecafbfe25bed98add87a53809355b80d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 3 Nov 2025 11:18:32 +1100 Subject: [PATCH 107/219] PR feedback --- .../prosettings/BaseStateProScreen.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt index 4e6d5ff690..1aba04d45c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/BaseStateProScreen.kt @@ -24,17 +24,17 @@ fun BaseStateProScreen( onBack: () -> Unit, successContent: @Composable (T) -> Unit ) { - when (state) { - is State.Error -> { + // in the case of an error + val context = LocalContext.current + LaunchedEffect(state) { + if (state is State.Error) { // show a toast and go back to pro settings home screen - val context = LocalContext.current - - LaunchedEffect(Unit) { - Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() - onBack() - } + Toast.makeText(context, R.string.errorGeneric, Toast.LENGTH_LONG).show() + onBack() } + } + when (state) { is State.Loading -> { Box( modifier = Modifier.fillMaxSize(), @@ -48,5 +48,7 @@ fun BaseStateProScreen( } is State.Success -> successContent(state.value) + + else -> {} } } \ No newline at end of file From 486f0ed7254bab742a50319cdbc1f66e5d36020d Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 3 Nov 2025 00:39:41 +0000 Subject: [PATCH 108/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 1 - app/src/main/res/values-b+ca+ES/strings.xml | 1 - app/src/main/res/values-b+cs+CZ/strings.xml | 13 ------------- app/src/main/res/values-b+de+DE/strings.xml | 1 - app/src/main/res/values-b+es+419/strings.xml | 1 - app/src/main/res/values-b+es+ES/strings.xml | 1 - app/src/main/res/values-b+fr+FR/strings.xml | 1 - app/src/main/res/values-b+hi+IN/strings.xml | 1 - app/src/main/res/values-b+it+IT/strings.xml | 1 - app/src/main/res/values-b+ja+JP/strings.xml | 1 - app/src/main/res/values-b+nl+NL/strings.xml | 1 - app/src/main/res/values-b+pl+PL/strings.xml | 1 - app/src/main/res/values-b+pt+PT/strings.xml | 1 - app/src/main/res/values-b+ro+RO/strings.xml | 1 - app/src/main/res/values-b+ru+RU/strings.xml | 1 - app/src/main/res/values-b+sv+SE/strings.xml | 1 - app/src/main/res/values-b+tr+TR/strings.xml | 1 - app/src/main/res/values-b+uk+UA/strings.xml | 1 - app/src/main/res/values-b+zh+CN/strings.xml | 1 - app/src/main/res/values-b+zh+TW/strings.xml | 1 - app/src/main/res/values/strings.xml | 4 ++-- 21 files changed, 2 insertions(+), 34 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index 4189fbdb13..fc79671d79 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -911,7 +911,6 @@ {price} - rüblük haqq Daha uzun mesajlar göndərmək istəyirsiniz? {app_pro} ilə daha çox mətn göndərin və premium özəlliklərin kilidini açın Daha çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın - 5-dən çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın {pro} statusunu təzələmə xətası Müddəti bitib Tezliklə bitir diff --git a/app/src/main/res/values-b+ca+ES/strings.xml b/app/src/main/res/values-b+ca+ES/strings.xml index f67d819d9e..b3b587a465 100644 --- a/app/src/main/res/values-b+ca+ES/strings.xml +++ b/app/src/main/res/values-b+ca+ES/strings.xml @@ -826,7 +826,6 @@ Penja els gifs amb Voleu enviar missatges més llargs? Envia més text i desbloqueja funcions premium amb {app_pro} Vols més pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro} - Vols més de 5 pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro} Xateja de grups més grans fins a 300 membres Plus carrega funcions més exclusives Missatges de fins a 10,000 caràcters diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index 8e97b3c80e..bbad3090df 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -565,18 +565,6 @@ Chcete odstranit {name} ze skupiny {group_name}? Chcete odstranit {name} a {count} dalších ze skupiny {group_name}? Chcete odstranit {name} a {other_name} ze skupiny {group_name}? - - Odstranit uživatele a jeho zprávy - Odstranit uživatele a jejich zprávy - Odstranit uživatele a jejich zprávy - Odstranit uživatele a jejich zprávy - - - Odstranit uživatele - Odstranit uživatele - Odstranit uživatele - Odstranit uživatele - {name} byl odebrán ze skupiny. {name} a {count} dalších byli odebráni ze skupiny. {name} a {other_name} byli odebráni ze skupiny. @@ -956,7 +944,6 @@ {price} účtováno čtvrtletně Chcete posílat delší zprávy? Posílejte více textu odemknutím prémiových funkcí Session Pro Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro - Chcete více než 5 připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro Zrušit Chyba obnovování stavu {pro} Platnost vypršela diff --git a/app/src/main/res/values-b+de+DE/strings.xml b/app/src/main/res/values-b+de+DE/strings.xml index a7a4420610..aa515a4c1a 100644 --- a/app/src/main/res/values-b+de+DE/strings.xml +++ b/app/src/main/res/values-b+de+DE/strings.xml @@ -878,7 +878,6 @@ Automatische {pro} Erneuerung in {time} Du möchtest längere Nachrichten senden? Sende mehr Text und schalte Premium-Funktionen mit {app_pro} frei Mehr Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei - Mehr als 5 Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei Abgelaufen {pro} FAQ GIF- und WebP-Profilbilder hochladen diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 70e1a031f2..dc025bdfdc 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -832,7 +832,6 @@ Sube GIFs con ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} - ¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más diff --git a/app/src/main/res/values-b+es+ES/strings.xml b/app/src/main/res/values-b+es+ES/strings.xml index d2c8bfadf6..d25b3df105 100644 --- a/app/src/main/res/values-b+es+ES/strings.xml +++ b/app/src/main/res/values-b+es+ES/strings.xml @@ -832,7 +832,6 @@ Sube GIFs con ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} - ¿Quieres más de 5 conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index c3e01a6a3b..dc1bf3be23 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -909,7 +909,6 @@ {price} facturé trimestriellement Vous voulez envoyer des messages plus longs ? Envoyez plus de messages et débloqué les fonctionnalités premium avec {app_pro} Vous voulez plus de messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} - Vous voulez plus que 5 messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} Expiré Expiration imminente {pro} expire dans {time} diff --git a/app/src/main/res/values-b+hi+IN/strings.xml b/app/src/main/res/values-b+hi+IN/strings.xml index 66e3bfc015..ad3d436827 100644 --- a/app/src/main/res/values-b+hi+IN/strings.xml +++ b/app/src/main/res/values-b+hi+IN/strings.xml @@ -828,7 +828,6 @@ GIF अपलोड करें लंबे संदेश भेजना चाहते हैं? अधिक टेक्स्ट भेजें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें - 5 से अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें GIF और WebP डिस्प्ले तस्वीरें अपलोड करें 300 सदस्यों तक बड़े समूह चैट साथ में कई और विशेष सुविधाएं diff --git a/app/src/main/res/values-b+it+IT/strings.xml b/app/src/main/res/values-b+it+IT/strings.xml index c9633b0a16..813021c95a 100644 --- a/app/src/main/res/values-b+it+IT/strings.xml +++ b/app/src/main/res/values-b+it+IT/strings.xml @@ -828,7 +828,6 @@ Carica GIF con Vuoi inviare messaggi più lunghi? Invia più testo e sblocca funzionalità premium con {app_pro} Vuoi più chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro} - Vuoi più di 5 chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro} Carica immagini profilo in formato GIF e WebP Chat di gruppo maggiori fino a 300 membri E tante altre funzionalità esclusive diff --git a/app/src/main/res/values-b+ja+JP/strings.xml b/app/src/main/res/values-b+ja+JP/strings.xml index 6a2aac7f24..8f4aacc126 100644 --- a/app/src/main/res/values-b+ja+JP/strings.xml +++ b/app/src/main/res/values-b+ja+JP/strings.xml @@ -806,7 +806,6 @@ GIFをアップロード(PRO) 長文を送りたいですか?{app_pro}でより多くのテキストを送り、プレミアム機能を解除しましょう。 さらにピン留めしますか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう - 5件以上ピン留めしたいですか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう GIFとWebPのディスプレイ画像をアップロード 最大300人の大型グループチャット さらに多数の限定機能 diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index cf6041168a..78174b8c25 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -902,7 +902,6 @@ {price} per kwartaal gefactureerd Wil je langere berichten versturen? Verstuur meer tekst en ontgrendel premiumfuncties met {app_pro} Wil je meer vastzetten? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} - Wil je meer dan 5 vastgezette gesprekken? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} Verlopen Verloopt binnenkort {pro} FAQ diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index 9759ed9491..29bbf17c1c 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -948,7 +948,6 @@ Opłata kwartalna: {price} Chcesz wysyłać dłuższe wiadomości? Wyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} Chcesz przypinać więcej czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} - Chcesz przypiąć więcej niż 5 czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} Niedługo wygaśnie FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index bb75737d14..b5383e4fc8 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -828,7 +828,6 @@ Carregue GIFs com Quer enviar mensagens mais longas? Envie mais texto e desbloqueie funcionalidades premium com {app_pro} Quer fixar mais conversas? Organize os seus chats e desbloqueie funcionalidades premium com o {app_pro} - Quer fixar mais de 5 conversas? Organize os seus chats e desbloqueie funcionalidades premium com {app_pro} Carregue imagens de exibição em GIF e WebP Conversas de grupo maiores com até 300 membros E muitas outras funcionalidades exclusivas diff --git a/app/src/main/res/values-b+ro+RO/strings.xml b/app/src/main/res/values-b+ro+RO/strings.xml index dc65f4480a..b159d85719 100644 --- a/app/src/main/res/values-b+ro+RO/strings.xml +++ b/app/src/main/res/values-b+ro+RO/strings.xml @@ -870,7 +870,6 @@ Încarcă GIF-uri cu Vrei să trimiți mesaje mai lungi? Trimite mai mult text și deblochează funcții premium cu {app_pro} Vrei mai multe fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} - Vrei mai mult de 5 fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} Încarcă imagini de profil GIF și WebP Conversații de grup mai mari, cu până la 300 de membri Și multe alte funcționalități exclusive diff --git a/app/src/main/res/values-b+ru+RU/strings.xml b/app/src/main/res/values-b+ru+RU/strings.xml index 7247fd421e..6453b14b09 100644 --- a/app/src/main/res/values-b+ru+RU/strings.xml +++ b/app/src/main/res/values-b+ru+RU/strings.xml @@ -922,7 +922,6 @@ Загружайте GIF с Хотите отправлять более длинные сообщения? Отправляйте больше текста и используйте премиум функции с {app_pro} Хотите больше закреплений? Организуйте свои чаты и получайте доступ к премиум функциям с {app_pro} - Нужно более 5 закреплений? С {app_pro} организуйте свои чаты и получите доступ к премиум функциям Загрузка изображений в формате GIF и WebP Групповые чаты до 300 участников + множество эксклюзивных функций diff --git a/app/src/main/res/values-b+sv+SE/strings.xml b/app/src/main/res/values-b+sv+SE/strings.xml index 506d6cce74..c331962463 100644 --- a/app/src/main/res/values-b+sv+SE/strings.xml +++ b/app/src/main/res/values-b+sv+SE/strings.xml @@ -882,7 +882,6 @@ Ladda upp GIF:ar med Vill du skicka längre meddelanden? Skicka mer text och lås upp premiumfunktioner med {app_pro} Vill du ha fler fästen? Organisera dina chattar och lås upp premiumfunktioner med {app_pro} - Vill du ha mer än 5 fästisar? Organisera dina chattar och lås upp premiumfunktioner med {app_pro} Ladda upp GIF- och WebP-visningsbilder Större gruppchattar upp till 300 medlemmar Plus många fler exklusiva funktioner diff --git a/app/src/main/res/values-b+tr+TR/strings.xml b/app/src/main/res/values-b+tr+TR/strings.xml index 098aa4f727..31e6060a3b 100644 --- a/app/src/main/res/values-b+tr+TR/strings.xml +++ b/app/src/main/res/values-b+tr+TR/strings.xml @@ -824,7 +824,6 @@ ile GIF Yükleyin Daha uzun mesajlar mı göndermek istiyorsunuz? {app_pro} ile daha fazla metin gönderin ve premium özelliklerin kilidini açın Daha fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın - 5\'ten fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın GIF ve WebP profil resmi yükleme 300 üyeye kadar daha büyük grup sohbetleri Ayrıca daha birçok özel özellik diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index 9c34c445c7..3e1d6cc560 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -949,7 +949,6 @@ {price} сплата щоквартально Хочете відправляти довші повідомлення? Надсилайте більше тексту та розблокуйте преміальні функції застосунку з Session Pro Потрібно більше закріплених бесід? Впорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} - Потрібно понад 5 закріплених бесід? Впорядкуйте свої бесіди та розблокуйте преміальні функції з {app_pro} Підписка сплила Невдовзі спливе підписка {pro} спливає за {time} diff --git a/app/src/main/res/values-b+zh+CN/strings.xml b/app/src/main/res/values-b+zh+CN/strings.xml index 14375a7b0e..228b348d01 100644 --- a/app/src/main/res/values-b+zh+CN/strings.xml +++ b/app/src/main/res/values-b+zh+CN/strings.xml @@ -803,7 +803,6 @@ 使用 PRO 上传 GIF 想发送更长的消息?使用 {app_pro} 发送更多文本并解锁高级功能 想要固定更多对话?使用 {app_pro} 整理你的聊天并解锁高级功能 - 想要固定超过 5 个对话?使用 {app_pro} 整理你的聊天并解锁高级功能 上传 GIF 和 WebP 头像 更大的群组聊天,最多可容纳 300 名成员 还有更多专属功能等你解锁 diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 164ca371f2..476980dfb9 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -806,7 +806,6 @@ 使用 {app_pro} 上傳 GIF 圖片 想傳送更長的訊息嗎?與 {app_pro} 一起傳送更多文字並解鎖進階功能 想要釘選更多對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能 - 想要釘選超過 5 則對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能 上傳 GIF 和 WebP 顯示圖片 最大支援 300 位成員的大型群組聊天室 以及更多獨家功能 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 83f7deeac6..01d1ab5eab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -224,7 +224,7 @@ Change your password for {app_name}. Locally stored data will be re-encrypted with your new password. Checking {pro} Status Checking your {pro} status. You\'ll be able to continue once this check is complete. - Checking your {pro} details. Some information on this page may be unavailable until this check is complete. + Checking your {pro} details. Some actions on this page may be unavailable until this check is complete. Checking {pro} Status... Checking your {pro} details. You cannot renew until this check is complete. Checking your {pro} status. You\'ll be able to upgrade to {pro} once this check is complete. @@ -1022,7 +1022,7 @@ accepted an invite to join the group. {price} Billed Quarterly Want to send longer messages? Send more text and unlock premium features with {app_pro} Want more pins? Organize your chats and unlock premium features with {app_pro} - Want more than 5 pins? Organize your chats and unlock premium features with {app_pro} + Want more than {limit} pins? Organize your chats and unlock premium features with {app_pro} Sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {pro} access. Cancellation Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. From b874898f79e5916b41486876ea0125f89a7b72ef Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 3 Nov 2025 15:18:06 +1100 Subject: [PATCH 109/219] Merge branch 'feature/update-crowdin-translations' of https://github.com/session-foundation/session-android into feature/update-crowdin-translations --- .../java/org/thoughtcrime/securesms/pro/ProStatusManager.kt | 2 +- .../main/java/org/thoughtcrime/securesms/ui/ProComponents.kt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 943ef828d4..ff6a23b1f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -278,7 +278,7 @@ class ProStatusManager @Inject constructor( companion object { const val MAX_CHARACTER_PRO = 10000 // max characters in a message for pro users private const val MAX_CHARACTER_REGULAR = 2000 // max characters in a message for non pro users - private const val MAX_PIN_REGULAR = 5 // max pinned conversation 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" const val DEFAULT_GOOGLE_STORE = "Google Play Store" diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index d5d395fcb0..86e5989152 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -84,9 +84,11 @@ import network.loki.messenger.R import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.APP_PRO_KEY +import org.session.libsession.utilities.StringSubstitutionConstants.LIMIT_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsDestination import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsNavHost +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionType import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect @@ -683,6 +685,7 @@ fun PinProCTA( .toString() else -> Phrase.from(context, R.string.proCallToActionPinnedConversationsMoreThan) + .put(LIMIT_KEY, ProStatusManager.MAX_PIN_REGULAR.toString()) .put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO) .format() .toString() From 996eb1c83cec0ee375a19add7f6270c997723a3f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:48:29 +0000 Subject: [PATCH 110/219] Bump org.mockito.kotlin:mockito-kotlin from 6.0.0 to 6.1.0 (#1658) --- 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 f632e491ef..e131d9d175 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,7 +49,7 @@ photoviewVersion = "2.3.0" phraseVersion = "1.2.0" lifecycleVersion = "2.9.4" materialVersion = "1.13.0" -mockitoKotlinVersion = "6.0.0" +mockitoKotlinVersion = "6.1.0" okhttpVersion = "5.1.0" preferenceVersion = "1.2.1" protobufVersion = "4.32.1" From 5059de9ebf843598a4e7095482b9fec364044100 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:20:45 +0000 Subject: [PATCH 111/219] Bump uiTestJunit4Version from 1.9.3 to 1.9.4 (#1660) --- 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 e131d9d175..96a919efa0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -65,7 +65,7 @@ subsamplingScaleImageViewVersion = "3.10.0" testCoreVersion = "1.7.0" truthVersion = "1.4.5" turbineVersion = "1.2.1" -uiTestJunit4Version = "1.9.3" +uiTestJunit4Version = "1.9.4" workRuntimeKtxVersion = "2.10.5" zxingVersion = "3.5.3" huaweiPushVersion = "6.13.0.300" From 21e49694bd47dbadd014f1fa6f959d6f35de0391 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Wed, 5 Nov 2025 11:46:30 +1100 Subject: [PATCH 112/219] Fixed button sizing issue due to crossfade --- .../group/CreateGroupScreen.kt | 6 ++++-- .../thoughtcrime/securesms/ui/Components.kt | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt index b84c0de572..f45bad3bfc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupScreen.kt @@ -65,8 +65,8 @@ fun CreateGroupScreen( viewModel.events.collect { event -> when (event) { is CreateGroupEvent.NavigateToConversation -> { - onClose() onNavigateToConversationScreen(event.address) + onClose() } is CreateGroupEvent.Error -> { @@ -190,7 +190,9 @@ fun CreateGroup( .padding(horizontal = LocalDimensions.current.spacing) .qaTag(R.string.AccessibilityId_groupCreate) ) { - LoadingArcOr(loading = showLoading) { + LoadingArcOr( + loading = showLoading + ) { Text(stringResource(R.string.create)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 9183d20c93..5b10c2c273 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform @@ -23,6 +24,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -34,6 +36,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -640,15 +643,17 @@ fun LaunchedEffectAsync(block: suspend CoroutineScope.() -> Unit) { @Composable fun LoadingArcOr(loading: Boolean, content: @Composable () -> Unit) { - Crossfade(loading) { isLoading -> - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (isLoading) { - SmallCircularProgressIndicator(color = LocalContentColor.current) - } else { - content() - } + AnimatedContent( + targetState = loading, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + contentAlignment = Alignment.Center, + label = "LoadingArcOr" + ) { isLoading -> + if (isLoading) { + SmallCircularProgressIndicator(color = LocalContentColor.current) + } else { + content() } - } } From aebdf191f49550909084157031bc66544eb22dd3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 22:13:08 +0000 Subject: [PATCH 113/219] Bump com.squareup.okhttp3:okhttp from 5.1.0 to 5.3.0 (#1659) --- 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 96a919efa0..ca93ac2782 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ phraseVersion = "1.2.0" lifecycleVersion = "2.9.4" materialVersion = "1.13.0" mockitoKotlinVersion = "6.1.0" -okhttpVersion = "5.1.0" +okhttpVersion = "5.3.0" preferenceVersion = "1.2.1" protobufVersion = "4.32.1" recyclerviewVersion = "1.4.0" From 57d4db19dbfa668527b705087709e8f37325e485 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 6 Nov 2025 13:12:02 +1100 Subject: [PATCH 114/219] Making the base subscriptionManager an abstract class to share core logic --- .../securesms/pro/ProStatusManager.kt | 7 +++ .../subscription/NoOpSubscriptionManager.kt | 10 ++-- .../subscription/SubscriptionCoordinator.kt | 3 +- .../pro/subscription/SubscriptionManager.kt | 53 ++++++++++++++----- .../PlayStoreSubscriptionManager.kt | 26 +++------ gradle/libs.versions.toml | 2 +- 6 files changed, 63 insertions(+), 38 deletions(-) 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 ff6a23b1f2..a0c7563ada 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -271,6 +271,13 @@ class ProStatusManager @Inject constructor( return emptySet() } + suspend fun appProPaymentToBackend() { + //todo PRO call AddProPaymentRequest in libsession + + // we should `AddProPaymentRequest` with exponential backoff + // the call might fail until the back end has had time to acknowledge the payment + } + enum class MessageProFeature { ProBadge, LongMessage, AnimatedAvatar } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt index 05375437ab..91de7bf5f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/NoOpSubscriptionManager.kt @@ -1,8 +1,11 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import javax.inject.Inject import javax.inject.Singleton @@ -11,7 +14,10 @@ import javax.inject.Singleton * An implementation representing a lack of support for subscription */ @Singleton -class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { +class NoOpSubscriptionManager @Inject constructor( + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "noop" override val name = "" override val description = "" @@ -27,8 +33,6 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager { override val availablePlans: List get() = emptyList() - override val purchaseEvents: SharedFlow = MutableSharedFlow() - override suspend fun hasValidSubscription(): Boolean { return false } diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt index 4eecc37f0c..64c8fb23d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionCoordinator.kt @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent @Singleton class SubscriptionCoordinator @Inject constructor( private val availableManagers: Set<@JvmSuppressWildcards SubscriptionManager>, + private val noopSubManager: NoOpSubscriptionManager, private val prefs: TextSecurePreferences ): OnAppStartupComponent { @@ -22,7 +23,7 @@ class SubscriptionCoordinator @Inject constructor( when { managers.isEmpty() -> { - currentManager = NoOpSubscriptionManager() + currentManager = noopSubManager } managers.size == 1 -> { currentManager = managers.first() diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 4606856cfc..6c3d9ddd74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -1,28 +1,35 @@ package org.thoughtcrime.securesms.pro.subscription +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent +import org.thoughtcrime.securesms.pro.ProStatusManager import java.time.Instant /** * Represents the implementation details of a given subscription provider */ -interface SubscriptionManager: OnAppStartupComponent { - val id: String - val name: String - val description: String - val iconRes: Int? +abstract class SubscriptionManager( + protected val proStatusManager: ProStatusManager, + @param:ManagerScope protected val scope: CoroutineScope, +): OnAppStartupComponent { + abstract val id: String + abstract val name: String + abstract val description: String + abstract val iconRes: Int? - val supportsBilling: StateFlow + abstract val supportsBilling: StateFlow // Optional. Some store can have a platform specific refund window and url - val quickRefundUrl: String? + abstract val quickRefundUrl: String? - val availablePlans: List + abstract val availablePlans: List sealed interface PurchaseEvent { data object Success : PurchaseEvent @@ -31,26 +38,46 @@ interface SubscriptionManager: OnAppStartupComponent { } // purchase events - val purchaseEvents: SharedFlow + protected val _purchaseEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() - suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result + abstract suspend fun purchasePlan(subscriptionDuration: ProSubscriptionDuration): Result /** * Returns true if a provider has a quick refunds and the current time since purchase is within that window */ - suspend fun isWithinQuickRefundWindow(): Boolean + abstract suspend fun isWithinQuickRefundWindow(): Boolean /** * Checks whether there is a valid subscription for the current user within this subscriber's billing API */ - suspend fun hasValidSubscription(): Boolean + abstract suspend fun hasValidSubscription(): Boolean /** * Gets a list of pricing for the subscriptions * @throws Exception in case of errors fetching prices */ @Throws(Exception::class) - suspend fun getSubscriptionPrices(): List + abstract suspend fun getSubscriptionPrices(): List + + /** + * Function called when a purchased has been made successfully from the subscription api + */ + protected fun onPurchaseSuccessful(){ + // we need to tie our purchase with the back end + scope.launch { + try { + proStatusManager.appProPaymentToBackend() + _purchaseEvents.emit(PurchaseEvent.Success) + } catch (e: Exception) { + _purchaseEvents.emit(PurchaseEvent.Failed()) + } + } + } data class SubscriptionPricing( val subscriptionDuration: ProSubscriptionDuration, diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 106a65a4f9..f8516dff3b 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -36,6 +36,7 @@ import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver import java.time.Instant @@ -49,10 +50,11 @@ import javax.inject.Singleton @Singleton class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, - @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, - private val prefs: TextSecurePreferences -) : SubscriptionManager { + private val prefs: TextSecurePreferences, + proStatusManager: ProStatusManager, + @param:ManagerScope scope: CoroutineScope, +) : SubscriptionManager(proStatusManager, scope) { override val id = "google_play_store" override val name = "Google Play Store" override val description = "" @@ -74,29 +76,13 @@ class PlayStoreSubscriptionManager @Inject constructor( override val quickRefundUrl = "https://support.google.com/googleplay/workflow/9813244" - private val _purchaseEvents = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - override val purchaseEvents: SharedFlow = _purchaseEvents.asSharedFlow() - private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> Log.d(TAG, "onPurchasesUpdated: $result, $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ - scope.launch { - // signal that purchase was completed - try { - //todo PRO send confirmation to libsession - } catch (e : Exception){ - _purchaseEvents.emit(PurchaseEvent.Failed()) - } - - _purchaseEvents.emit(PurchaseEvent.Success) - } + onPurchaseSuccessful() } } else { Log.w(TAG, "Purchase failed or cancelled: $result") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca93ac2782..96c41f6dd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ assertjCoreVersion = "3.27.6" biometricVersion = "1.1.0" cameraCamera2Version = "1.5.1" cardviewVersion = "1.0.0" -composeBomVersion = "2025.10.01" +composeBomVersion = "2025.11.00" conscryptAndroidVersion = "2.5.3" conscryptJavaVersion = "2.5.2" constraintlayoutVersion = "2.2.1" From 6fdb9addefd299d2a6f1f6dde8c216bec9229251 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 6 Nov 2025 16:31:03 +1100 Subject: [PATCH 115/219] Initial draft of the debug logger --- .../attachments/AvatarReuploadWorker.kt | 47 +++--- .../attachments/AvatarUploadManager.kt | 12 +- .../securesms/debugmenu/DebugActivity.kt | 4 +- .../securesms/debugmenu/DebugLogScreen.kt | 143 ++++++++++++++++++ .../securesms/debugmenu/DebugLogger.kt | 90 +++++++++++ .../securesms/debugmenu/DebugMenu.kt | 71 ++++++--- .../securesms/debugmenu/DebugMenuNavHost.kt | 111 ++++++++++++++ .../securesms/debugmenu/DebugMenuScreen.kt | 11 +- .../securesms/debugmenu/DebugMenuViewModel.kt | 40 ++++- .../prosettings/ProSettingsNavHost.kt | 2 +- .../thoughtcrime/securesms/ui/Components.kt | 2 +- .../PlayStoreSubscriptionManager.kt | 27 ++-- 12 files changed, 488 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index ac6369708a..cb44d1c2ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.attachments import android.content.Context -import android.widget.Toast import androidx.compose.ui.unit.IntSize import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy @@ -17,8 +16,6 @@ import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import network.loki.messenger.BuildConfig import okhttp3.HttpUrl.Companion.toHttpUrl import okio.BufferedSource @@ -31,8 +28,9 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup +import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.util.BitmapUtil -import org.thoughtcrime.securesms.util.CurrentActivityObserver import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.ImageUtils import java.time.Duration @@ -55,22 +53,15 @@ class AvatarReuploadWorker @AssistedInject constructor( private val avatarUploadManager: Lazy, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, private val fileServerApi: FileServerApi, - private val prefs: TextSecurePreferences, - private val currentActivityObserver: CurrentActivityObserver, + private val debugLogger: DebugLogger ) : CoroutineWorker(context, params) { /** * Log the given message and show a toast if in debug mode */ - private suspend inline fun logAndToast(message: String, e: Throwable? = null) { - Log.d(TAG, message, e) - - val context = currentActivityObserver.currentActivity.value ?: return - if (prefs.debugAvatarReupload || BuildConfig.DEBUG) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "AvatarReupload[debug only]: $message", Toast.LENGTH_SHORT).show() - } - } + private fun log(message: String, e: Throwable? = null) { + debugLogger.logD(message = "Avatar Reupload: $message", + group = DebugLogGroup.AVATAR, throwable = e) } override suspend fun doWork(): Result { @@ -79,13 +70,13 @@ class AvatarReuploadWorker @AssistedInject constructor( } if (profile == null) { - logAndToast("No profile picture set; nothing to do.") + log("No profile picture set; nothing to do.") return Result.success() } val localFile = AvatarDownloadManager.computeFileName(context, profile) if (!localFile.exists()) { - logAndToast("Avatar file is missing locally; nothing to do.") + log("Avatar file is missing locally; nothing to do.") return Result.success() } @@ -94,7 +85,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Check if the file exists and whether we need to do reprocessing, if we do, we reprocess and re-upload localEncryptedFileInputStreamFactory.create(localFile).use { stream -> if (stream.meta.hasPermanentDownloadError) { - logAndToast("Permanent download error for current avatar; nothing to do.") + log("Permanent download error for current avatar; nothing to do.") return Result.success() } @@ -103,7 +94,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val source = stream.source().buffer() if ((lastUpdated != null && needsReProcessing(source)) || lastUpdated == null) { - logAndToast("About to start reuploading avatar.") + log("About to start reuploading avatar.") val attachment = attachmentProcessor.processAvatar( data = source.use { it.readByteArray() }, ) ?: return Result.failure() @@ -118,14 +109,14 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: NonRetryableException) { - logAndToast("Non-retryable error while reuploading avatar.", e) + log("Non-retryable error while reuploading avatar.", e) return Result.failure() } catch (e: Exception) { - logAndToast("Error while reuploading avatar.", e) + log("Error while reuploading avatar.", e) return Result.retry() } - logAndToast("Successfully reuploaded avatar.") + log("Successfully reuploaded avatar.") return Result.success() } } @@ -133,7 +124,7 @@ class AvatarReuploadWorker @AssistedInject constructor( // Otherwise, we only need to renew the same avatar on the server val parsed = fileServerApi.parseAttachmentUrl(profile.url.toHttpUrl()) - logAndToast("Renewing user avatar on ${parsed.fileServer}") + log("Renewing user avatar on ${parsed.fileServer}") try { fileServerApi.renew( fileId = parsed.fileId, @@ -149,7 +140,7 @@ class AvatarReuploadWorker @AssistedInject constructor( val now = Instant.now() if (fileExpiry?.isBefore(now) == true || (lastUpdated?.isBefore(now.minus(Duration.ofDays(12)))) == true) { - logAndToast("FileServer renew failed, trying to upload", e) + log("FileServer renew failed, trying to upload", e) val pictureData = localEncryptedFileInputStreamFactory.create(localFile).use { stream -> check(!stream.meta.hasPermanentDownloadError) { @@ -166,18 +157,18 @@ class AvatarReuploadWorker @AssistedInject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - logAndToast("Error while reuploading avatar after renew failed.", e) + log("Error while reuploading avatar after renew failed.", e) return Result.failure() } - logAndToast("Successfully reuploaded avatar after renew failed.") + log("Successfully reuploaded avatar after renew failed.") } else { - logAndToast( "Not reuploading avatar after renew failed; last updated too recent.") + log( "Not reuploading avatar after renew failed; last updated too recent.") } return Result.success() } else { - logAndToast("Error while renewing avatar. Retrying...", e) + log("Error while renewing avatar. Retrying...", e) return Result.retry() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index b4bd8db154..7c1eb3797e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -21,6 +21,8 @@ import org.session.libsession.utilities.Util import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup +import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType @@ -42,6 +44,7 @@ class AvatarUploadManager @Inject constructor( private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, private val fileServerApi: FileServerApi, private val attachmentProcessor: AttachmentProcessor, + private val debugLogger: DebugLogger ) : OnAppStartupComponent { init { // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state @@ -99,7 +102,8 @@ class AvatarUploadManager @Inject constructor( customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } ) - Log.d(TAG, "Avatar upload finished with $uploadResult") + debugLogger.logD(message = "Avatar upload finished with $uploadResult", + group = DebugLogGroup.AVATAR, tag = TAG) val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(result.key)) @@ -111,7 +115,8 @@ class AvatarUploadManager @Inject constructor( it.write(pictureData) } - Log.d(TAG, "Avatar file written to local storage") + debugLogger.logD(message = "Avatar file written to local storage", + group = DebugLogGroup.AVATAR, tag = TAG) // Now that we have the file both locally and remotely, we can update the user profile val oldPic = configFactory.withMutableUserConfigs { @@ -134,7 +139,8 @@ class AvatarUploadManager @Inject constructor( // If we had an old avatar, delete it from local storage val oldFile = AvatarDownloadManager.computeFileName(application, oldPic) if (oldFile.exists()) { - Log.d(TAG, "Deleting old avatar file: $oldFile") + debugLogger.logD(message = "Deleting old avatar file: $oldFile", + group = DebugLogGroup.AVATAR, tag = TAG) oldFile.delete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt index d89c3b78c0..458968bb4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugActivity.kt @@ -9,8 +9,8 @@ class DebugActivity : FullComposeActivity() { @Composable override fun ComposeContent() { - DebugMenuScreen( - onClose = { finish() } + DebugMenuNavHost( + onBack = { finish() } ) } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt new file mode 100644 index 0000000000..4c9bde6852 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -0,0 +1,143 @@ +package org.thoughtcrime.securesms.debugmenu + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors +import org.thoughtcrime.securesms.ui.theme.bold +import org.thoughtcrime.securesms.ui.theme.monospace +import java.time.Duration +import java.time.Instant + + +@Composable +fun DebugLogScreen( + viewModel: DebugMenuViewModel, + onBack: () -> Unit, +){ + val logs by viewModel.debugLogs.collectAsState() + + DebugLogs( + logs = logs, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DebugLogs( + logs: List, + onBack: () -> Unit, +){ + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + // App bar + BackAppBar(title = "Debug Logs", onBack = onBack) + }, + ) { contentPadding -> + val scrollState = rememberLazyListState() + + Cell( + modifier = Modifier.fillMaxSize() + .padding(contentPadding) + .padding(LocalDimensions.current.smallSpacing), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + state = scrollState + ) { + items(items = logs){ log -> + Column { + Row { + Text( + text = log.formattedDate, + style = LocalType.current.small.bold() + ) + + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = "[${log.group.label}]", + style = LocalType.current.small.bold().copy( + color = log.group.color + ) + ) + } + + Spacer(Modifier.height(2.dp)) + + Text( + text = log.message, + style = LocalType.current.large.monospace().bold() + ) + } + } + } + } + } +} + +@Preview +@Composable +fun PrewviewDebugLogs( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors +) { + PreviewTheme(colors) { + DebugLogs( + logs = listOf( + DebugLogData( + message = "This is a log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now(), + formattedDate = "10: 36" + ), + DebugLogData( + message = "This is another log", + group = DebugLogGroup.PRO_SUBSCRIPTION, + date = Instant.now() - Duration.ofMinutes(4), + formattedDate = "10: 36" + ), + DebugLogData( + message = "This is also a log", + group = DebugLogGroup.AVATAR, + date = Instant.now() - Duration.ofMinutes(7), + formattedDate = "10: 36" + ), + ), + onBack = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt new file mode 100644 index 0000000000..c42363b011 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.app.Application +import android.widget.Toast +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.ui.theme.primaryGreen +import org.thoughtcrime.securesms.ui.theme.primaryOrange +import org.thoughtcrime.securesms.util.DateUtils +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A class that keeps track of certain logs and allows certain logs to pop as toasts + */ +@Singleton +class DebugLogger @Inject constructor( + private val application: Application, + private val prefs: TextSecurePreferences, + private val dateUtils: DateUtils +){ + private val prefPrefix: String = "debug_logger_" + + private val _logs: MutableStateFlow> = MutableStateFlow(emptyList()) + val logs: StateFlow> = _logs + + fun showGroupToast(group: DebugLogGroup, showToast: Boolean){ + prefs.setBooleanPreference(prefPrefix + group.label, showToast) + } + + fun getGroupToastPreference(group: DebugLogGroup): Boolean{ + return prefs.getBooleanPreference(prefPrefix + group.label, false) + } + + fun log(message: String, group: DebugLogGroup, tag: String = "", logSeverity: LogSeverity = LogSeverity.INFO, throwable: Throwable? = null){ + // add this message to our list + val date = Instant.now() + _logs.update { + (it + DebugLogData( + message = message, + group = group, + date = date, + formattedDate = dateUtils.getLocaleFormattedTime(date.toEpochMilli()) + )).sortedByDescending { log -> log.date } + } + + // log the message + when(logSeverity){ + LogSeverity.INFO -> Log.d(tag, message, throwable) + LogSeverity.WARNING -> Log.w(tag, message, throwable) + LogSeverity.ERROR -> Log.e(tag, message, throwable) + } + + // show this as a toast if the prefs have this group toggled + if(prefs.getBooleanPreference(prefPrefix + group.label, false)){ + Toast.makeText(application, message, Toast.LENGTH_LONG).show() + } + } + + fun logD(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ + log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.INFO) + } + fun logW(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ + log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.WARNING) + } + fun logE(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ + log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.ERROR) + } +} + +data class DebugLogData( + val message: String, + val group: DebugLogGroup, + val date: Instant, + val formattedDate: String +) + +enum class LogSeverity{ + INFO, WARNING, ERROR +} + +enum class DebugLogGroup(val label: String, val color: Color){ + AVATAR("Avatar", primaryOrange), PRO_SUBSCRIPTION("Pro Subscription", primaryGreen) +} \ No newline at end of file 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 4db3947687..3249da0ef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -71,13 +71,15 @@ import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonData import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.LoadingDialog +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.Button import org.thoughtcrime.securesms.ui.components.ButtonType import org.thoughtcrime.securesms.ui.components.DropDown import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField import org.thoughtcrime.securesms.ui.components.SessionSwitch -import org.thoughtcrime.securesms.ui.components.SlimOutlineButton +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect +import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -216,17 +218,34 @@ fun DebugMenu( ) } - if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { - DebugCell("Database inspector") { - Button( - onClick = { - sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) - }, - text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) - "Start" - else "Stop", - type = ButtonType.AccentFill, - ) + // Debug Logger + DebugCell( + "Debug Logger", + verticalArrangement = Arrangement.spacedBy(0.dp)) { + Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing)) + + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + text = "Show Debug Logs", + ) { + sendCommand(DebugMenuViewModel.Commands.NavigateTo(DebugMenuDestination.DebugMenuLogs)) + } + + Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) + + Column { + DebugLogGroup.entries.forEach { logGroup -> + DebugSwitchRow( + text = "Show toasts for ${logGroup.label}", + checked = uiState.showToastForGroups[logGroup.label] == true, + onCheckedChange = { + sendCommand(DebugMenuViewModel.Commands.ToggleDebugLogGroup( + group = logGroup, + showToast = it) + ) + } + ) + } } } @@ -409,6 +428,20 @@ fun DebugMenu( } } + if (uiState.dbInspectorState != DebugMenuViewModel.DatabaseInspectorState.NOT_AVAILABLE) { + DebugCell("Database inspector") { + SlimFillButtonRect( + modifier = Modifier.fillMaxWidth(), + onClick = { + sendCommand(DebugMenuViewModel.Commands.ToggleDatabaseInspector) + }, + text = if (uiState.dbInspectorState == DebugMenuViewModel.DatabaseInspectorState.STOPPED) + "Start" + else "Stop", + ) + } + } + // Fake contacts DebugCell("Generate fake contacts") { var prefix by remember { mutableStateOf("User-") } @@ -433,7 +466,7 @@ fun DebugMenu( ) } - SlimOutlineButton(modifier = Modifier.fillMaxWidth(), text = "Generate") { + SlimFillButtonRect(modifier = Modifier.fillMaxWidth(), text = "Generate") { sendCommand( GenerateContacts( prefix = prefix, @@ -446,7 +479,7 @@ fun DebugMenu( // Session Token DebugCell("Session Token") { // Schedule a test token-drop notification for 10 seconds from now - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Schedule Token Page Notification (10s)", onClick = { sendCommand(ScheduleTokenNotification) } @@ -456,7 +489,7 @@ fun DebugMenu( // Keys DebugCell("User Details") { - SlimOutlineButton ( + SlimFillButtonRect ( text = "Copy Account ID", modifier = Modifier.fillMaxWidth(), onClick = { @@ -464,7 +497,7 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( text = "Copy 07-prefixed Version Blinded Public Key", modifier = Modifier.fillMaxWidth(), onClick = { @@ -493,7 +526,7 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { @@ -542,14 +575,14 @@ fun DebugMenu( } ) - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Reset Push Token", ) { sendCommand(DebugMenuViewModel.Commands.ResetPushToken) } - SlimOutlineButton( + SlimFillButtonRect( modifier = Modifier.fillMaxWidth(), text = "Clear All Trusted Downloads", ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt new file mode 100644 index 0000000000..0eb22ac784 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -0,0 +1,111 @@ +package org.thoughtcrime.securesms.debugmenu + +import android.annotation.SuppressLint +import android.os.Parcelable +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.thoughtcrime.securesms.ui.NavigationAction +import org.thoughtcrime.securesms.ui.ObserveAsEvents +import org.thoughtcrime.securesms.ui.UINavigator +import org.thoughtcrime.securesms.ui.horizontalSlideComposable + +// Destinations +sealed interface DebugMenuDestination: Parcelable { + @Serializable + @Parcelize + data object DebugMenuHome: DebugMenuDestination + + @Serializable + @Parcelize + data object DebugMenuLogs: DebugMenuDestination + +} + +@Serializable object DebugMenuGraph + +@SuppressLint("RestrictedApi") +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun DebugMenuNavHost( + startDestination: DebugMenuDestination = DebugMenuDestination.DebugMenuHome, + onBack: () -> Unit +){ + val navController = rememberNavController() + val navigator: UINavigator = remember { + UINavigator() + } + + val handleBack: () -> Unit = { + if (navController.previousBackStackEntry != null) { + navController.navigateUp() + } else { + onBack() // Finish activity if at root + } + } + + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } + + NavigationAction.NavigateUp -> handleBack() + + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) + } + + is NavigationAction.ReturnResult -> {} + } + } + + NavHost( + navController = navController, + startDestination = DebugMenuGraph + ) { + navigation(startDestination = startDestination) { + // Home + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugMenuScreen( + viewModel = viewModel, + onBack = onBack + ) + } + + // Logs + horizontalSlideComposable { entry -> + val viewModel = navController.debugGraphViewModel(entry, navigator) + + DebugLogScreen( + viewModel = viewModel, + onBack = handleBack + ) + } + } + } +} + +@Composable +private fun NavController.debugGraphViewModel( + entry: androidx.navigation.NavBackStackEntry, + navigator: UINavigator +): DebugMenuViewModel { + val graphEntry = remember(entry) { getBackStackEntry(DebugMenuGraph) } + return hiltViewModel< + DebugMenuViewModel, + DebugMenuViewModel.Factory + >(graphEntry) { factory -> factory.create(navigator) } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt index 6c0f22805a..7162f89014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuScreen.kt @@ -4,20 +4,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun DebugMenuScreen( modifier: Modifier = Modifier, - debugMenuViewModel: DebugMenuViewModel = viewModel(), - onClose: () -> Unit + viewModel: DebugMenuViewModel, + onBack: () -> Unit ) { - val uiState by debugMenuViewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsState() DebugMenu( modifier = modifier, uiState = uiState, - sendCommand = debugMenuViewModel::onCommand, - onClose = onClose + sendCommand = viewModel::onCommand, + onClose = onBack ) } 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 fb2b6786cf..0779a3d9b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -7,14 +7,17 @@ import android.os.Build import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -39,17 +42,20 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager +import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.ClearDataUtils import java.time.ZonedDateTime import javax.inject.Inject -@HiltViewModel -class DebugMenuViewModel @Inject constructor( +@HiltViewModel(assistedFactory = DebugMenuViewModel.Factory::class) +class DebugMenuViewModel @AssistedInject constructor( + @Assisted private val navigator: UINavigator, @param:ApplicationContext private val context: Context, private val textSecurePreferences: TextSecurePreferences, private val tokenPageNotificationManager: TokenPageNotificationManager, @@ -62,10 +68,16 @@ class DebugMenuViewModel @Inject constructor( private val conversationRepository: ConversationRepository, private val databaseInspector: DatabaseInspector, private val tokenFetcher: TokenFetcher, + private val debugLogger: DebugLogger, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" + @AssistedFactory + interface Factory { + fun create(navigator: UINavigator): DebugMenuViewModel + } + private val _uiState = MutableStateFlow( UIState( currentEnvironment = textSecurePreferences.getEnvironment().label, @@ -113,11 +125,14 @@ class DebugMenuViewModel @Inject constructor( withinQuickRefund = textSecurePreferences.getDebugIsWithinQuickRefund(), availableAltFileServers = TEST_FILE_SERVERS, alternativeFileServer = textSecurePreferences.alternativeFileServer, + showToastForGroups = getDebugGroupToastPref() ) ) val uiState: StateFlow get() = _uiState + val debugLogs = debugLogger.logs + init { if (databaseInspector.available) { viewModelScope.launch { @@ -368,9 +383,25 @@ class DebugMenuViewModel @Inject constructor( _uiState.update { it.copy(alternativeFileServer = command.fileServer) } textSecurePreferences.alternativeFileServer = command.fileServer } + + is Commands.NavigateTo -> { + viewModelScope.launch { + navigator.navigate(command.destination) + } + } + + is Commands.ToggleDebugLogGroup -> { + debugLogger.showGroupToast(command.group, command.showToast) + } } } + private fun getDebugGroupToastPref(): Map { + return DebugLogGroup.entries.associate { group -> + group.label to debugLogger.getGroupToastPreference(group) + } + } + private fun showEnvironmentWarningDialog(environment: String) { if(environment == _uiState.value.currentEnvironment) return val env = Environment.entries.firstOrNull { it.label == environment } ?: return @@ -482,6 +513,7 @@ class DebugMenuViewModel @Inject constructor( val withinQuickRefund: Boolean, val alternativeFileServer: FileServer? = null, val availableAltFileServers: List = emptyList(), + val showToastForGroups: Map = emptyMap(), ) enum class DatabaseInspectorState { @@ -539,6 +571,8 @@ class DebugMenuViewModel @Inject constructor( data object ToggleDebugAvatarReupload : Commands() data object ResetPushToken : Commands() data class SelectAltFileServer(val fileServer: FileServer?) : Commands() + data class NavigateTo(val destination: DebugMenuDestination) : Commands() + data class ToggleDebugLogGroup(val group: DebugLogGroup, val showToast: Boolean) : Commands() } companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index 82e904ad48..cc7b20e95e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -156,7 +156,7 @@ fun ProSettingsNavHost( } @Composable -fun NavController.proGraphViewModel( +private fun NavController.proGraphViewModel( entry: androidx.navigation.NavBackStackEntry, navigator: UINavigator ): ProSettingsViewModel { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 5b10c2c273..26239d97db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -419,7 +419,7 @@ fun Cell( modifier: Modifier = Modifier, dropShadow: Boolean = false, bgColor: Color = LocalColors.current.backgroundSecondary, - content: @Composable () -> Unit + content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 106a65a4f9..2640368585 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -35,6 +35,9 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.debugmenu.DebugLogGroup +import org.thoughtcrime.securesms.debugmenu.DebugLogger +import org.thoughtcrime.securesms.debugmenu.LogSeverity import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver @@ -51,7 +54,8 @@ class PlayStoreSubscriptionManager @Inject constructor( private val application: Application, @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, - private val prefs: TextSecurePreferences + private val prefs: TextSecurePreferences, + private val debugLogger: DebugLogger ) : SubscriptionManager { override val id = "google_play_store" override val name = "Google Play Store" @@ -84,7 +88,7 @@ class PlayStoreSubscriptionManager @Inject constructor( private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> - Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + debugLogger.logD(message = "onPurchasesUpdated: $result, $purchases", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ scope.launch { @@ -99,7 +103,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } } } else { - Log.w(TAG, "Purchase failed or cancelled: $result") + debugLogger.logW(message = "Purchase failed or cancelled: $result", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) scope.launch { _purchaseEvents.emit(PurchaseEvent.Cancelled) } @@ -156,7 +160,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // If user has an existing subscription, configure upgrade/downgrade if (existingPurchase != null) { - Log.d(TAG, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") + debugLogger.log(message = "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) billingFlowParamsBuilder.setSubscriptionUpdateParams( BillingFlowParams.SubscriptionUpdateParams.newBuilder() @@ -183,7 +187,8 @@ class PlayStoreSubscriptionManager @Inject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error purchase plan", e) + debugLogger.logE(message = "Error purchase plan", group = DebugLogGroup.PRO_SUBSCRIPTION, + throwable = e, tag = TAG) withContext(Dispatchers.Main) { Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() @@ -218,7 +223,8 @@ class PlayStoreSubscriptionManager @Inject constructor( } override fun onBillingSetupFinished(result: BillingResult) { - Log.d(TAG, "onBillingSetupFinished with $result") + debugLogger.log(message = "onBillingSetupFinished with $result", group = DebugLogGroup.PRO_SUBSCRIPTION, + tag = TAG) if (result.responseCode == BillingClient.BillingResponseCode.OK) { _playBillingAvailable.update { true } } @@ -243,7 +249,8 @@ class PlayStoreSubscriptionManager @Inject constructor( it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? } } catch (e: Exception) { - Log.e(TAG, "Error querying existing subscription", e) + debugLogger.logE(message = "Error querying existing subscription", group = DebugLogGroup.PRO_SUBSCRIPTION, + throwable = e, tag = TAG) null } } @@ -278,7 +285,8 @@ class PlayStoreSubscriptionManager @Inject constructor( val productDetails = result.productDetailsList?.firstOrNull() ?: run { - Log.w(TAG, "No ProductDetails returned for product id session_pro") + debugLogger.logW(message = "No ProductDetails returned for product id session_pro", group = DebugLogGroup.PRO_SUBSCRIPTION, + tag = TAG) return emptyList() } @@ -290,7 +298,8 @@ class PlayStoreSubscriptionManager @Inject constructor( return availablePlans.mapNotNull { duration -> val offer = offersByBasePlan[duration.id] if (offer == null) { - Log.w(TAG, "No offer found for basePlanId=${duration.id}") + debugLogger.logW(message = "No offer found for basePlanId=${duration.id}", group = DebugLogGroup.PRO_SUBSCRIPTION, + tag = TAG) return@mapNotNull null } From 17655c855115528e4c8f0cdc0f406071382ba34c Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 6 Nov 2025 17:13:14 +1100 Subject: [PATCH 116/219] Added extra features: clear, copy all, long press copy row --- .../securesms/debugmenu/DebugLogScreen.kt | 119 ++++++++++++++---- .../securesms/debugmenu/DebugLogger.kt | 4 + .../securesms/debugmenu/DebugMenuViewModel.kt | 41 ++++++ 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt index 4c9bde6852..ed559412b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -1,12 +1,14 @@ package org.thoughtcrime.securesms.debugmenu import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -20,14 +22,26 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.CopyAccountId import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar +import org.thoughtcrime.securesms.ui.components.DropDown +import org.thoughtcrime.securesms.ui.qaTag import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType @@ -49,6 +63,7 @@ fun DebugLogScreen( DebugLogs( logs = logs, + sendCommand = viewModel::onCommand, onBack = onBack, ) } @@ -57,6 +72,7 @@ fun DebugLogScreen( @Composable fun DebugLogs( logs: List, + sendCommand: (DebugMenuViewModel.Commands) -> Unit, onBack: () -> Unit, ){ Scaffold( @@ -68,44 +84,94 @@ fun DebugLogs( ) { contentPadding -> val scrollState = rememberLazyListState() - Cell( + Column( modifier = Modifier.fillMaxSize() .padding(contentPadding) - .padding(LocalDimensions.current.smallSpacing), + .padding(LocalDimensions.current.smallSpacing) ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(LocalDimensions.current.smallSpacing), - verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), - state = scrollState + var filter: DebugLogGroup? by remember { mutableStateOf(null) } + + DropDown( + selected = filter, + values = DebugLogGroup.entries, + onValueSelected = { filter = it }, + labeler = { it?.label ?: "Show All" }, + allowSelectingNullValue = true, + ) + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Cell( + modifier = Modifier.weight(1f), ) { - items(items = logs){ log -> - Column { - Row { - Text( - text = log.formattedDate, - style = LocalType.current.small.bold() - ) + val haptics = LocalHapticFeedback.current - Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(LocalDimensions.current.smallSpacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing), + state = scrollState + ) { + items(items = logs.filter { filter == null || it.group == filter }) { log -> + Column( + modifier = Modifier.fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + sendCommand(DebugMenuViewModel.Commands.CopyLog(log)) + } + ) + } + ) { + Row { + Text( + text = log.formattedDate, + style = LocalType.current.small.bold() + ) - Text( - text = "[${log.group.label}]", - style = LocalType.current.small.bold().copy( - color = log.group.color + Spacer(Modifier.width(LocalDimensions.current.xxsSpacing)) + + Text( + text = "[${log.group.label}]", + style = LocalType.current.small.bold().copy( + color = log.group.color + ) ) - ) - } + } - Spacer(Modifier.height(2.dp)) + Spacer(Modifier.height(2.dp)) - Text( - text = log.message, - style = LocalType.current.large.monospace().bold() - ) + Text( + text = log.message, + style = LocalType.current.large.monospace().bold() + ) + } } } } + + Spacer(Modifier.height(LocalDimensions.current.xsSpacing)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxsSpacing) + ) { + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Copy all logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.CopyAllLogs) + } + ) + AccentFillButtonRect( + modifier = Modifier.weight(1f), + text = "Clear logs", + onClick = { + sendCommand(DebugMenuViewModel.Commands.ClearAllDebugLogs) + } + ) + } } } } @@ -137,6 +203,7 @@ fun PrewviewDebugLogs( formattedDate = "10: 36" ), ), + sendCommand = {}, onBack = {} ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index c42363b011..6a68553ab9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -72,6 +72,10 @@ class DebugLogger @Inject constructor( fun logE(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.ERROR) } + + fun clearAllLogs(){ + _logs.update { emptyList() } + } } data class DebugLogData( 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 0779a3d9b2..574ec5d38a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -393,6 +393,44 @@ class DebugMenuViewModel @AssistedInject constructor( is Commands.ToggleDebugLogGroup -> { debugLogger.showGroupToast(command.group, command.showToast) } + + is Commands.ClearAllDebugLogs -> { + debugLogger.clearAllLogs() + } + + is Commands.CopyAllLogs -> { + val logs = debugLogger.logs.value.joinToString("\n\n") { + "${it.formattedDate}: ${it.message}" + } + + val clip = ClipData.newPlainText("Debug Logs", logs) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Logs to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + + is Commands.CopyLog -> { + val log = "${command.log.formattedDate}: ${command.log.message}" + + val clip = ClipData.newPlainText("Debug Log", log) + clipboardManager.setPrimaryClip(ClipData(clip)) + + // Show a toast if the version is below Android 13 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + context, + "Copied Debug Log to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } } } @@ -573,6 +611,9 @@ class DebugMenuViewModel @AssistedInject constructor( data class SelectAltFileServer(val fileServer: FileServer?) : Commands() data class NavigateTo(val destination: DebugMenuDestination) : Commands() data class ToggleDebugLogGroup(val group: DebugLogGroup, val showToast: Boolean) : Commands() + data object ClearAllDebugLogs : Commands() + data object CopyAllLogs : Commands() + data class CopyLog(val log: DebugLogData) : Commands() } companion object { From b0fb60ffd155eb1325f5a26173aac41db1e3a5e9 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Thu, 6 Nov 2025 17:28:44 +1100 Subject: [PATCH 117/219] Fixing up toasts --- .../thoughtcrime/securesms/debugmenu/DebugLogger.kt | 11 +++++++++-- .../securesms/debugmenu/DebugMenuViewModel.kt | 3 +++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index 6a68553ab9..af25b1304c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -3,12 +3,16 @@ package org.thoughtcrime.securesms.debugmenu import android.app.Application import android.widget.Toast import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.ui.theme.primaryGreen import org.thoughtcrime.securesms.ui.theme.primaryOrange import org.thoughtcrime.securesms.util.DateUtils @@ -23,7 +27,8 @@ import javax.inject.Singleton class DebugLogger @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, - private val dateUtils: DateUtils + private val dateUtils: DateUtils, + @ManagerScope private val scope: CoroutineScope ){ private val prefPrefix: String = "debug_logger_" @@ -59,7 +64,9 @@ class DebugLogger @Inject constructor( // show this as a toast if the prefs have this group toggled if(prefs.getBooleanPreference(prefPrefix + group.label, false)){ - Toast.makeText(application, message, Toast.LENGTH_LONG).show() + scope.launch(Dispatchers.Main) { + Toast.makeText(application.applicationContext, message, Toast.LENGTH_LONG).show() + } } } 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 574ec5d38a..01813f42b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -392,6 +392,9 @@ class DebugMenuViewModel @AssistedInject constructor( is Commands.ToggleDebugLogGroup -> { debugLogger.showGroupToast(command.group, command.showToast) + _uiState.update { + it.copy(showToastForGroups = getDebugGroupToastPref()) + } } is Commands.ClearAllDebugLogs -> { From 2a815aaa5b617186c2b8c59cb169ea7da96598b7 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 13:41:45 +1100 Subject: [PATCH 118/219] PR Feedback: Limiting log creation - only on subscribe now --- .../securesms/debugmenu/DebugLogScreen.kt | 12 +---- .../securesms/debugmenu/DebugLogger.kt | 53 ++++++++++++++----- .../securesms/debugmenu/DebugMenuViewModel.kt | 4 +- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt index ed559412b0..be4d8abf41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -1,6 +1,5 @@ package org.thoughtcrime.securesms.debugmenu -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -17,7 +16,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -25,24 +23,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import network.loki.messenger.R -import org.thoughtcrime.securesms.conversation.v2.settings.ConversationSettingsViewModel.Commands.CopyAccountId +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.components.AccentFillButtonRect import org.thoughtcrime.securesms.ui.components.BackAppBar import org.thoughtcrime.securesms.ui.components.DropDown -import org.thoughtcrime.securesms.ui.qaTag -import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions import org.thoughtcrime.securesms.ui.theme.LocalType import org.thoughtcrime.securesms.ui.theme.PreviewTheme @@ -59,7 +51,7 @@ fun DebugLogScreen( viewModel: DebugMenuViewModel, onBack: () -> Unit, ){ - val logs by viewModel.debugLogs.collectAsState() + val logs by viewModel.debugLogs.collectAsStateWithLifecycle(initialValue = emptyList()) DebugLogs( logs = logs, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index af25b1304c..9cd6725ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -5,9 +5,13 @@ import android.widget.Toast import androidx.compose.ui.graphics.Color import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.session.libsession.utilities.TextSecurePreferences @@ -20,6 +24,8 @@ import java.time.Instant import javax.inject.Inject import javax.inject.Singleton +private const val MAX_LOG_ENTRIES = 200 + /** * A class that keeps track of certain logs and allows certain logs to pop as toasts */ @@ -32,8 +38,22 @@ class DebugLogger @Inject constructor( ){ private val prefPrefix: String = "debug_logger_" - private val _logs: MutableStateFlow> = MutableStateFlow(emptyList()) - val logs: StateFlow> = _logs + private val buffer = ArrayDeque(MAX_LOG_ENTRIES) + + private val logChanges = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + // should only run when collected + val logSnapshots: Flow> = + logChanges + .onStart { emit(Unit) } + .map { currentSnapshot() } + + fun currentSnapshot(): List = + synchronized(buffer) { buffer.toList().asReversed() } fun showGroupToast(group: DebugLogGroup, showToast: Boolean){ prefs.setBooleanPreference(prefPrefix + group.label, showToast) @@ -45,14 +65,20 @@ class DebugLogger @Inject constructor( fun log(message: String, group: DebugLogGroup, tag: String = "", logSeverity: LogSeverity = LogSeverity.INFO, throwable: Throwable? = null){ // add this message to our list - val date = Instant.now() - _logs.update { - (it + DebugLogData( - message = message, - group = group, - date = date, - formattedDate = dateUtils.getLocaleFormattedTime(date.toEpochMilli()) - )).sortedByDescending { log -> log.date } + val now = Instant.now() + val entry = DebugLogData( + message = message, + group = group, + date = now, + formattedDate = dateUtils.getLocaleFormattedTime(now.toEpochMilli()) + ) + + scope.launch(Dispatchers.Default) { + synchronized(buffer) { + if (buffer.size == MAX_LOG_ENTRIES) buffer.removeFirst() + buffer.addLast(entry) + } + logChanges.tryEmit(Unit) } // log the message @@ -80,8 +106,11 @@ class DebugLogger @Inject constructor( log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.ERROR) } - fun clearAllLogs(){ - _logs.update { emptyList() } + fun clearAllLogs() { + scope.launch(Dispatchers.Default) { + synchronized(buffer) { buffer.clear() } + logChanges.tryEmit(Unit) + } } } 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 01813f42b9..26ae413c5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -131,7 +131,7 @@ class DebugMenuViewModel @AssistedInject constructor( val uiState: StateFlow get() = _uiState - val debugLogs = debugLogger.logs + val debugLogs = debugLogger.logSnapshots init { if (databaseInspector.available) { @@ -402,7 +402,7 @@ class DebugMenuViewModel @AssistedInject constructor( } is Commands.CopyAllLogs -> { - val logs = debugLogger.logs.value.joinToString("\n\n") { + val logs = debugLogger.currentSnapshot().joinToString("\n\n") { "${it.formattedDate}: ${it.message}" } From dbb7ef1bf3b10e562ff68e1ba0865778807d082d Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 14:19:49 +1100 Subject: [PATCH 119/219] Making use of our existing Logging structure --- .../securesms/ApplicationContext.kt | 4 +- .../attachments/AvatarReuploadWorker.kt | 7 +- .../attachments/AvatarUploadManager.kt | 13 +-- .../securesms/debugmenu/DebugLogger.kt | 101 +++++++++--------- .../securesms/debugmenu/DebugMenuViewModel.kt | 3 +- .../PlayStoreSubscriptionManager.kt | 24 ++--- 6 files changed, 66 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt index 1ebbedd2bb..e5787d787e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.kt @@ -51,6 +51,7 @@ import org.session.libsignal.utilities.HTTP.isConnectedToNetwork import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.AppContext.configureKovenant import org.thoughtcrime.securesms.debugmenu.DebugActivity +import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.dependencies.DatabaseComponent import org.thoughtcrime.securesms.dependencies.DatabaseModule.init import org.thoughtcrime.securesms.dependencies.OnAppStartupComponents @@ -89,6 +90,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio @Inject lateinit var startupComponents: Lazy @Inject lateinit var persistentLogger: Lazy + @Inject lateinit var debugLogger: Lazy @Inject lateinit var textSecurePreferences: Lazy @Inject lateinit var migrationManager: Lazy @@ -208,7 +210,7 @@ class ApplicationContext : Application(), DefaultLifecycleObserver, Configuratio } private fun initializeLogging() { - Log.initialize(AndroidLogger(), persistentLogger.get()) + Log.initialize(AndroidLogger(), persistentLogger.get(), debugLogger.get()) Logger.addLogger(object : Logger { private val tag = "LibSession" diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt index cb44d1c2ca..0c1aca9c25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarReuploadWorker.kt @@ -29,7 +29,6 @@ import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemote import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugLogGroup -import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.DateUtils.Companion.secondsToInstant import org.thoughtcrime.securesms.util.ImageUtils @@ -52,16 +51,14 @@ class AvatarReuploadWorker @AssistedInject constructor( private val configFactory: ConfigFactoryProtocol, private val avatarUploadManager: Lazy, private val localEncryptedFileInputStreamFactory: LocalEncryptedFileInputStream.Factory, - private val fileServerApi: FileServerApi, - private val debugLogger: DebugLogger + private val fileServerApi: FileServerApi ) : CoroutineWorker(context, params) { /** * Log the given message and show a toast if in debug mode */ private fun log(message: String, e: Throwable? = null) { - debugLogger.logD(message = "Avatar Reupload: $message", - group = DebugLogGroup.AVATAR, throwable = e) + Log.d(DebugLogGroup.AVATAR.label, "Avatar Reupload: $message", e) } override suspend fun doWork(): Result { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt index 7c1eb3797e..d9ffb76596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AvatarUploadManager.kt @@ -22,7 +22,6 @@ import org.session.libsession.utilities.recipients.RemoteFile import org.session.libsession.utilities.recipients.RemoteFile.Companion.toRemoteFile import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugLogGroup -import org.thoughtcrime.securesms.debugmenu.DebugLogger import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent import org.thoughtcrime.securesms.util.castAwayType @@ -43,8 +42,7 @@ class AvatarUploadManager @Inject constructor( @ManagerScope scope: CoroutineScope, private val localEncryptedFileOutputStreamFactory: LocalEncryptedFileOutputStream.Factory, private val fileServerApi: FileServerApi, - private val attachmentProcessor: AttachmentProcessor, - private val debugLogger: DebugLogger + private val attachmentProcessor: AttachmentProcessor ) : OnAppStartupComponent { init { // Manage scheduling/cancellation of the AvatarReuploadWorker based on login state @@ -102,8 +100,7 @@ class AvatarUploadManager @Inject constructor( customExpiresDuration = DEBUG_AVATAR_TTL.takeIf { prefs.forcedShortTTL() } ) - debugLogger.logD(message = "Avatar upload finished with $uploadResult", - group = DebugLogGroup.AVATAR, tag = TAG) + Log.d(DebugLogGroup.AVATAR.label, "Avatar upload finished with $uploadResult") val remoteFile = RemoteFile.Encrypted(url = uploadResult.fileUrl, key = Bytes(result.key)) @@ -115,8 +112,7 @@ class AvatarUploadManager @Inject constructor( it.write(pictureData) } - debugLogger.logD(message = "Avatar file written to local storage", - group = DebugLogGroup.AVATAR, tag = TAG) + Log.d(DebugLogGroup.AVATAR.label, "Avatar file written to local storage") // Now that we have the file both locally and remotely, we can update the user profile val oldPic = configFactory.withMutableUserConfigs { @@ -139,8 +135,7 @@ class AvatarUploadManager @Inject constructor( // If we had an old avatar, delete it from local storage val oldFile = AvatarDownloadManager.computeFileName(application, oldPic) if (oldFile.exists()) { - debugLogger.logD(message = "Deleting old avatar file: $oldFile", - group = DebugLogGroup.AVATAR, tag = TAG) + Log.d(DebugLogGroup.AVATAR.label, "Deleting old avatar file: $oldFile") oldFile.delete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index 9cd6725ed5..62c1c0a36b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -28,90 +28,89 @@ private const val MAX_LOG_ENTRIES = 200 /** * A class that keeps track of certain logs and allows certain logs to pop as toasts + * To use: Set the tag as one of the known [DebugLogGroup] */ @Singleton class DebugLogger @Inject constructor( - private val application: Application, + private val app: Application, private val prefs: TextSecurePreferences, private val dateUtils: DateUtils, @ManagerScope private val scope: CoroutineScope -){ +) : Log.Logger() { private val prefPrefix: String = "debug_logger_" private val buffer = ArrayDeque(MAX_LOG_ENTRIES) private val logChanges = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST + replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) - // should only run when collected + private val allowedTags: Set = + DebugLogGroup.entries.map { it.label.lowercase() }.toSet() + + private fun groupForTag(tag: String): DebugLogGroup? = + DebugLogGroup.entries.firstOrNull { it.label.equals(tag, ignoreCase = true) } + val logSnapshots: Flow> = - logChanges - .onStart { emit(Unit) } - .map { currentSnapshot() } + logChanges.onStart { emit(Unit) }.map { currentSnapshot() } fun currentSnapshot(): List = synchronized(buffer) { buffer.toList().asReversed() } - fun showGroupToast(group: DebugLogGroup, showToast: Boolean){ - prefs.setBooleanPreference(prefPrefix + group.label, showToast) + fun clearAll() { + synchronized(buffer) { buffer.clear() } + logChanges.tryEmit(Unit) } - fun getGroupToastPreference(group: DebugLogGroup): Boolean{ - return prefs.getBooleanPreference(prefPrefix + group.label, false) + fun getGroupToastPreference(group: DebugLogGroup): Boolean = + prefs.getBooleanPreference(prefPrefix + group.label, false) + + fun showGroupToast(group: DebugLogGroup, showToast: Boolean) { + prefs.setBooleanPreference(prefPrefix + group.label, showToast) } - fun log(message: String, group: DebugLogGroup, tag: String = "", logSeverity: LogSeverity = LogSeverity.INFO, throwable: Throwable? = null){ - // add this message to our list + // ---- Log.Logger overrides (no “level” logic) ---- + override fun v(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun d(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun i(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun w(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun e(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun wtf(tag: String, message: String?, t: Throwable?) = add(tag, message, t) + override fun blockUntilAllWritesFinished() { /* no-op */ } + + private fun add(tag: String, message: String?, t: Throwable?) { + // Capture ONLY if tag is in our allow-list + if (!allowedTags.contains(tag.lowercase())) return + + val group = groupForTag(tag) ?: return + val now = Instant.now() + val text = when { + !message.isNullOrBlank() -> message + t != null -> t.localizedMessage ?: t::class.java.simpleName + else -> "" // nothing meaningful + } + val entry = DebugLogData( - message = message, + message = text, group = group, date = now, formattedDate = dateUtils.getLocaleFormattedTime(now.toEpochMilli()) ) - scope.launch(Dispatchers.Default) { - synchronized(buffer) { - if (buffer.size == MAX_LOG_ENTRIES) buffer.removeFirst() - buffer.addLast(entry) - } - logChanges.tryEmit(Unit) - } - - // log the message - when(logSeverity){ - LogSeverity.INFO -> Log.d(tag, message, throwable) - LogSeverity.WARNING -> Log.w(tag, message, throwable) - LogSeverity.ERROR -> Log.e(tag, message, throwable) + synchronized(buffer) { + if (buffer.size == MAX_LOG_ENTRIES) buffer.removeFirst() + buffer.addLast(entry) } + logChanges.tryEmit(Unit) - // show this as a toast if the prefs have this group toggled - if(prefs.getBooleanPreference(prefPrefix + group.label, false)){ + // Toast decision is independent from capture. + if (getGroupToastPreference(group)) { scope.launch(Dispatchers.Main) { - Toast.makeText(application.applicationContext, message, Toast.LENGTH_LONG).show() + Toast.makeText(app, text, Toast.LENGTH_SHORT).show() } } } - - fun logD(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ - log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.INFO) - } - fun logW(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ - log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.WARNING) - } - fun logE(message: String, group: DebugLogGroup, tag: String = "", throwable: Throwable? = null){ - log(message = message, group = group, tag = tag, throwable = throwable, logSeverity = LogSeverity.ERROR) - } - - fun clearAllLogs() { - scope.launch(Dispatchers.Default) { - synchronized(buffer) { buffer.clear() } - logChanges.tryEmit(Unit) - } - } } data class DebugLogData( @@ -121,10 +120,6 @@ data class DebugLogData( val formattedDate: String ) -enum class LogSeverity{ - INFO, WARNING, ERROR -} - enum class DebugLogGroup(val label: String, val color: Color){ AVATAR("Avatar", primaryOrange), PRO_SUBSCRIPTION("Pro Subscription", primaryGreen) } \ No newline at end of file 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 26ae413c5a..82ce9b5ac2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -42,7 +42,6 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel import org.thoughtcrime.securesms.pro.ProStatusManager import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager import org.thoughtcrime.securesms.repository.ConversationRepository @@ -398,7 +397,7 @@ class DebugMenuViewModel @AssistedInject constructor( } is Commands.ClearAllDebugLogs -> { - debugLogger.clearAllLogs() + debugLogger.clearAll() } is Commands.CopyAllLogs -> { diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index 2640368585..dce3d0ee46 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -36,8 +36,6 @@ import network.loki.messenger.R import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.debugmenu.DebugLogGroup -import org.thoughtcrime.securesms.debugmenu.DebugLogger -import org.thoughtcrime.securesms.debugmenu.LogSeverity import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager.PurchaseEvent import org.thoughtcrime.securesms.util.CurrentActivityObserver @@ -55,7 +53,6 @@ class PlayStoreSubscriptionManager @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, private val currentActivityObserver: CurrentActivityObserver, private val prefs: TextSecurePreferences, - private val debugLogger: DebugLogger ) : SubscriptionManager { override val id = "google_play_store" override val name = "Google Play Store" @@ -88,7 +85,7 @@ class PlayStoreSubscriptionManager @Inject constructor( private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> - debugLogger.logD(message = "onPurchasesUpdated: $result, $purchases", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onPurchasesUpdated: $result, $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ scope.launch { @@ -103,7 +100,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } } } else { - debugLogger.logW(message = "Purchase failed or cancelled: $result", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "Purchase failed or cancelled: $result") scope.launch { _purchaseEvents.emit(PurchaseEvent.Cancelled) } @@ -160,7 +157,7 @@ class PlayStoreSubscriptionManager @Inject constructor( // If user has an existing subscription, configure upgrade/downgrade if (existingPurchase != null) { - debugLogger.log(message = "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION", group = DebugLogGroup.PRO_SUBSCRIPTION, tag = TAG) + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "Found existing subscription, configuring upgrade/downgrade with WITHOUT_PRORATION") billingFlowParamsBuilder.setSubscriptionUpdateParams( BillingFlowParams.SubscriptionUpdateParams.newBuilder() @@ -187,8 +184,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } catch (e: CancellationException) { throw e } catch (e: Exception) { - debugLogger.logE(message = "Error purchase plan", group = DebugLogGroup.PRO_SUBSCRIPTION, - throwable = e, tag = TAG) + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error purchase plan", e) withContext(Dispatchers.Main) { Toast.makeText(application, application.getString(R.string.errorGeneric), Toast.LENGTH_LONG).show() @@ -223,8 +219,7 @@ class PlayStoreSubscriptionManager @Inject constructor( } override fun onBillingSetupFinished(result: BillingResult) { - debugLogger.log(message = "onBillingSetupFinished with $result", group = DebugLogGroup.PRO_SUBSCRIPTION, - tag = TAG) + Log.d(DebugLogGroup.PRO_SUBSCRIPTION.label, "onBillingSetupFinished with $result") if (result.responseCode == BillingClient.BillingResponseCode.OK) { _playBillingAvailable.update { true } } @@ -249,8 +244,7 @@ class PlayStoreSubscriptionManager @Inject constructor( it.purchaseState == Purchase.PurchaseState.PURCHASED //todo PRO Should we also OR PENDING here? } } catch (e: Exception) { - debugLogger.logE(message = "Error querying existing subscription", group = DebugLogGroup.PRO_SUBSCRIPTION, - throwable = e, tag = TAG) + Log.e(DebugLogGroup.PRO_SUBSCRIPTION.label, "Error querying existing subscription", e) null } } @@ -285,8 +279,7 @@ class PlayStoreSubscriptionManager @Inject constructor( val productDetails = result.productDetailsList?.firstOrNull() ?: run { - debugLogger.logW(message = "No ProductDetails returned for product id session_pro", group = DebugLogGroup.PRO_SUBSCRIPTION, - tag = TAG) + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No ProductDetails returned for product id session_pro") return emptyList() } @@ -298,8 +291,7 @@ class PlayStoreSubscriptionManager @Inject constructor( return availablePlans.mapNotNull { duration -> val offer = offersByBasePlan[duration.id] if (offer == null) { - debugLogger.logW(message = "No offer found for basePlanId=${duration.id}", group = DebugLogGroup.PRO_SUBSCRIPTION, - tag = TAG) + Log.w(DebugLogGroup.PRO_SUBSCRIPTION.label, "No offer found for basePlanId=${duration.id}") return@mapNotNull null } From 442e4aaab44eff91e29221ca92d3f9fa43ec901e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 15:28:30 +1100 Subject: [PATCH 120/219] PR feedback --- .../securesms/debugmenu/DebugLogScreen.kt | 3 ++- .../securesms/debugmenu/DebugLogger.kt | 20 +++++++++++++------ .../securesms/debugmenu/DebugMenuViewModel.kt | 4 ++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt index be4d8abf41..03948f2af3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -51,7 +51,8 @@ fun DebugLogScreen( viewModel: DebugMenuViewModel, onBack: () -> Unit, ){ - val logs by viewModel.debugLogs.collectAsStateWithLifecycle(initialValue = emptyList()) + val flowLogs = remember { viewModel.debugLogs } + val logs by flowLogs.collectAsStateWithLifecycle(initialValue = emptyList()) DebugLogs( logs = logs, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index 62c1c0a36b..81ce49d7b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -51,8 +51,15 @@ class DebugLogger @Inject constructor( private fun groupForTag(tag: String): DebugLogGroup? = DebugLogGroup.entries.firstOrNull { it.label.equals(tag, ignoreCase = true) } - val logSnapshots: Flow> = - logChanges.onStart { emit(Unit) }.map { currentSnapshot() } + val logSnapshots: Flow> + get() = logChanges.onStart { emit(Unit) }.map { currentSnapshot() } + + // In-memory cache for toast prefs + private val toastEnabled = java.util.EnumMap(DebugLogGroup::class.java).apply { + DebugLogGroup.entries.forEach { group -> + this[group] = prefs.getBooleanPreference(prefPrefix + group.label, false) + } + } fun currentSnapshot(): List = synchronized(buffer) { buffer.toList().asReversed() } @@ -62,13 +69,14 @@ class DebugLogger @Inject constructor( logChanges.tryEmit(Unit) } - fun getGroupToastPreference(group: DebugLogGroup): Boolean = - prefs.getBooleanPreference(prefPrefix + group.label, false) - fun showGroupToast(group: DebugLogGroup, showToast: Boolean) { + toastEnabled[group] = showToast prefs.setBooleanPreference(prefPrefix + group.label, showToast) } + fun getGroupToastPreference(group: DebugLogGroup): Boolean = + toastEnabled[group] == true + // ---- Log.Logger overrides (no “level” logic) ---- override fun v(tag: String, message: String?, t: Throwable?) = add(tag, message, t) override fun d(tag: String, message: String?, t: Throwable?) = add(tag, message, t) @@ -105,7 +113,7 @@ class DebugLogger @Inject constructor( logChanges.tryEmit(Unit) // Toast decision is independent from capture. - if (getGroupToastPreference(group)) { + if (toastEnabled[group] == true) { scope.launch(Dispatchers.Main) { Toast.makeText(app, text, Toast.LENGTH_SHORT).show() } 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 82ce9b5ac2..be23f0810c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -14,6 +14,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -49,7 +50,6 @@ import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.ClearDataUtils import java.time.ZonedDateTime -import javax.inject.Inject @HiltViewModel(assistedFactory = DebugMenuViewModel.Factory::class) @@ -130,7 +130,7 @@ class DebugMenuViewModel @AssistedInject constructor( val uiState: StateFlow get() = _uiState - val debugLogs = debugLogger.logSnapshots + val debugLogs: Flow> get() = debugLogger.logSnapshots init { if (databaseInspector.available) { From e121174db6553b3f51aee8f85c828ecf1515feaf Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 15:41:07 +1100 Subject: [PATCH 121/219] Added logging --- app/build.gradle.kts | 3 +-- .../securesms/preferences/prosettings/ProSettingsViewModel.kt | 1 - .../securesms/pro/subscription/SubscriptionManager.kt | 1 + .../securesms/pro/subscription/PlayStoreSubscriptionManager.kt | 3 ++- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ef76a3438..30750fe264 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -202,11 +202,10 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") - applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix(".debug") + setAuthorityPostfix("") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 7f35456a45..d77cd8da41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -82,7 +82,6 @@ class ProSettingsViewModel @AssistedInject constructor( val cancelPlanState: StateFlow> = _cancelPlanState init { - Log.w("", "*** VM INIT") // observe subscription status viewModelScope.launch { proStatusManager.subscriptionState.collect { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index 6c3d9ddd74..c977771607 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -69,6 +69,7 @@ abstract class SubscriptionManager( */ protected fun onPurchaseSuccessful(){ // we need to tie our purchase with the back end + scope.launch { try { proStatusManager.appProPaymentToBackend() diff --git a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt index f8516dff3b..cfd6e87d5c 100644 --- a/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt +++ b/app/src/play/kotlin/org/thoughtcrime/securesms/pro/subscription/PlayStoreSubscriptionManager.kt @@ -79,9 +79,10 @@ class PlayStoreSubscriptionManager @Inject constructor( private val billingClient by lazy { BillingClient.newBuilder(application) .setListener { result, purchases -> - Log.d(TAG, "onPurchasesUpdated: $result, $purchases") + Log.d(TAG, "Billing callback. Result: $result, Purchases: $purchases") if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { purchases.firstOrNull()?.let{ + Log.d(TAG, "Billing callback. We have a purchase [${it.orderId}]. Acknowledged? ${it.isAcknowledged}") onPurchaseSuccessful() } } else { From cc108e9f8241f9c89ac82f9cb97a8bac6fb412e2 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 15:41:31 +1100 Subject: [PATCH 122/219] Undoing gradle change --- app/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 30750fe264..7ef76a3438 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -202,10 +202,11 @@ android { enableUnitTestCoverage = false signingConfig = signingConfigs.getByName("debug") + applicationIdSuffix = ".${name}" enablePermissiveNetworkSecurityConfig(true) devNetDefaultOn(false) setAlternativeAppName("Session Debug") - setAuthorityPostfix("") + setAuthorityPostfix(".debug") } } From d883edefca84222934859e800c3fea8d966ba867 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Fri, 7 Nov 2025 16:00:51 +1100 Subject: [PATCH 123/219] Local time formatting in compose - fine for debug screen --- .../securesms/debugmenu/DebugLogScreen.kt | 14 ++++++++++---- .../securesms/debugmenu/DebugLogger.kt | 2 -- .../securesms/debugmenu/DebugMenuViewModel.kt | 6 ++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt index 03948f2af3..bc197eb605 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogScreen.kt @@ -42,8 +42,12 @@ import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.bold import org.thoughtcrime.securesms.ui.theme.monospace +import org.thoughtcrime.securesms.util.DateUtils import java.time.Duration import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale @Composable @@ -118,8 +122,13 @@ fun DebugLogs( } ) { Row { + val locale = remember(Unit) { Locale.getDefault() } + val formatter = remember(Unit){ DateTimeFormatter.ofPattern("HH:mm", locale)} + Text( - text = log.formattedDate, + text = Instant.ofEpochMilli(log.date.toEpochMilli()) + .atZone(ZoneId.systemDefault()) + .format(formatter), style = LocalType.current.small.bold() ) @@ -181,19 +190,16 @@ fun PrewviewDebugLogs( message = "This is a log", group = DebugLogGroup.PRO_SUBSCRIPTION, date = Instant.now(), - formattedDate = "10: 36" ), DebugLogData( message = "This is another log", group = DebugLogGroup.PRO_SUBSCRIPTION, date = Instant.now() - Duration.ofMinutes(4), - formattedDate = "10: 36" ), DebugLogData( message = "This is also a log", group = DebugLogGroup.AVATAR, date = Instant.now() - Duration.ofMinutes(7), - formattedDate = "10: 36" ), ), sendCommand = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt index 81ce49d7b3..e1a0421c1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugLogger.kt @@ -103,7 +103,6 @@ class DebugLogger @Inject constructor( message = text, group = group, date = now, - formattedDate = dateUtils.getLocaleFormattedTime(now.toEpochMilli()) ) synchronized(buffer) { @@ -125,7 +124,6 @@ data class DebugLogData( val message: String, val group: DebugLogGroup, val date: Instant, - val formattedDate: String ) enum class DebugLogGroup(val label: String, val color: Color){ 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 be23f0810c..ad9d31c0ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.tokenpage.TokenPageNotificationManager import org.thoughtcrime.securesms.ui.UINavigator import org.thoughtcrime.securesms.util.ClearDataUtils +import org.thoughtcrime.securesms.util.DateUtils import java.time.ZonedDateTime @@ -68,6 +69,7 @@ class DebugMenuViewModel @AssistedInject constructor( private val databaseInspector: DatabaseInspector, private val tokenFetcher: TokenFetcher, private val debugLogger: DebugLogger, + private val dateUtils: DateUtils, subscriptionManagers: Set<@JvmSuppressWildcards SubscriptionManager>, ) : ViewModel() { private val TAG = "DebugMenu" @@ -402,7 +404,7 @@ class DebugMenuViewModel @AssistedInject constructor( is Commands.CopyAllLogs -> { val logs = debugLogger.currentSnapshot().joinToString("\n\n") { - "${it.formattedDate}: ${it.message}" + "${dateUtils.getLocaleFormattedTime(it.date.toEpochMilli())}: ${it.message}" } val clip = ClipData.newPlainText("Debug Logs", logs) @@ -419,7 +421,7 @@ class DebugMenuViewModel @AssistedInject constructor( } is Commands.CopyLog -> { - val log = "${command.log.formattedDate}: ${command.log.message}" + val log = "${dateUtils.getLocaleFormattedTime(command.log.date.toEpochMilli())}: ${command.log.message}" val clip = ClipData.newPlainText("Debug Log", log) clipboardManager.setPrimaryClip(ClipData(clip)) From 2f9cc9cbafd1b7cc5cb9551a000fbe61d1369c5d Mon Sep 17 00:00:00 2001 From: mpretty-cyro <15862619+mpretty-cyro@users.noreply.github.com> Date: Mon, 10 Nov 2025 00:44:40 +0000 Subject: [PATCH 124/219] [Automated] Update translations from Crowdin --- app/src/main/res/values-b+az+AZ/strings.xml | 161 +++++++++--- app/src/main/res/values-b+ca+ES/strings.xml | 2 - app/src/main/res/values-b+cs+CZ/strings.xml | 252 ++++++++++++++++++- app/src/main/res/values-b+de+DE/strings.xml | 2 - app/src/main/res/values-b+es+419/strings.xml | 3 - app/src/main/res/values-b+es+ES/strings.xml | 3 - app/src/main/res/values-b+fr+FR/strings.xml | 3 - app/src/main/res/values-b+hi+IN/strings.xml | 3 - app/src/main/res/values-b+hu+HU/strings.xml | 1 - app/src/main/res/values-b+it+IT/strings.xml | 3 - app/src/main/res/values-b+ja+JP/strings.xml | 3 - app/src/main/res/values-b+nl+NL/strings.xml | 11 +- app/src/main/res/values-b+pl+PL/strings.xml | 3 - app/src/main/res/values-b+pt+PT/strings.xml | 3 - app/src/main/res/values-b+ro+RO/strings.xml | 3 - app/src/main/res/values-b+ru+RU/strings.xml | 3 - app/src/main/res/values-b+sv+SE/strings.xml | 3 - app/src/main/res/values-b+tr+TR/strings.xml | 3 - app/src/main/res/values-b+uk+UA/strings.xml | 3 - app/src/main/res/values-b+zh+CN/strings.xml | 41 ++- app/src/main/res/values-b+zh+TW/strings.xml | 4 - app/src/main/res/values/strings.xml | 8 +- 22 files changed, 431 insertions(+), 90 deletions(-) diff --git a/app/src/main/res/values-b+az+AZ/strings.xml b/app/src/main/res/values-b+az+AZ/strings.xml index fc79671d79..81c872096d 100644 --- a/app/src/main/res/values-b+az+AZ/strings.xml +++ b/app/src/main/res/values-b+az+AZ/strings.xml @@ -1,5 +1,6 @@ + Bu qrupda admin olmayan üzv yoxdur. Haqqında Qəbul et Hesab Kimliyini kopyala @@ -15,15 +16,15 @@ Bu, sizin Hesab Kimliyinizdir. Digər istifadəçilər onu skan edərək sizinlə danışıq başlada bilər. Aktual həcmi Əlavə et + + Admin əlavə et + Adminləri əlavə et + İnzibatçılar əlavə et Admin edəcəyiniz istifadəçinin Hesab ID-sini daxil edin.\n\nBirdən çox istifadəçi əlavə etmək üçün vergüllə ayrılmış hər Hesab ID-sini daxil edin. Bir dəfəyə 20-yə qədər Hesab ID-si daxil edilə bilər. - Adminlər çıxarıla bilməz. {name}başqa {count} nəfər Admin olaraq yüksəldildi. Adminləri yüksəlt - {name} istifadəçisini admin etmək istədiyinizə əminsiniz? Adminlər çıxarıla bilməz. - {name}başqa {count} nəfəri admin etmək istədiyinizə əminsiniz? Adminlər çıxarıla bilməz. Admin olaraq yüksəlt - {name}{other_name} admin etmək istədiyinizə əminsiniz? Adminlər silinə bilməz. {name} Admin olaraq yüksəldildi. Admin təyinatı uğursuz oldu {name} istifadəçisini {group_name} qrupunda yüksəltmə uğursuz oldu @@ -32,21 +33,19 @@ Yüksəltmə göndərilmədi Admin təyinatı göndərildi Yüksəltmə statusu bilinmir - Adminləri çıxart - Admin olaraq sil Bu İcmada heç bir Admin yoxdur. - {name} istifadəçisini Admin olaraq çıxartma uğursuz oldu. - {name}digər {count} nəfər Adminlikdən çıxarılmadı. - {name}{other_name} Adminlikdən çıxarılmadı. {name} artıq Admin deyil. - {name}digər {count} nəfər Adminlikdən çıxarıldı. - {name}{other_name} Adminlikdən çıxarıldı. + + %1$d Admin seçildi + %1$d Admin seçildi + Admin təyinatı göndərilir Admin təyinatları göndərilir Admin ayarları {name}{other_name} Admin olaraq yüksəldildi. + Adminlər +{count} Anonim Tətbiq ikonu @@ -113,7 +112,6 @@ Fayl ölçüsü: Fayl növü: Bu danışıqda heç bir faylınız yoxdur. - Meta veri, fayldan silinə bilmir. Yeni media yüklənir... Yeni fayllar yüklənir... Köhnə media yüklənir... @@ -191,6 +189,9 @@ Digər istifadəçilərlə səsli və görüntülü zəngləri təmin edir. {name} istifadəçisinə zəng etdiniz Gizlilik Ayarlarında Səsli və görüntülü zənglər seçimini fəallaşdırmadığınız üçün {name} etdiyi bir zəngi buraxdınız. + Kamera erişiminə icazə vermək üçün ayarları aç və Kamera icazəsini işə sal. + Son zəng zamanı, görüntünü istifadə etməyə çalışdınız, ancaq kamera erişiminə daha əvvəl rədd cavabı verildiyi üçün edə bilmədiniz. Kamera erişiminə icazə vermək üçün ayarları açıb Kamera icazəsini işə salın. + Kamera erişimi tələb olunur Kamera tapılmadı Kamera əlçatmazdır. Kamera erişiminə icazə ver @@ -198,10 +199,12 @@ {app_name} foto və video çəkmək və ya QR kodlarını skan etmək üçün kameraya müraciət etməlidir. {app_name} QR kodlarını skan etmək üçün kameraya müraciət etməlidir İmtina + {pro} - ləğv et Dəyişdir Parol dəyişdirmə uğursuz oldu {app_name} üçün parolunuzu dəyişdirin. Daxili olaraq saxlanılmış verilər, yeni parolunuzla təkrar şifrələnəcək. {pro} statusu yoxlanılır + {pro} statusu yoxlanılır... {pro} statusunuz yoxlanılır. Bu yoxlama tamamlandıqdan sonra {pro} ya yüksəldə biləcəksiniz. Təmizlə Hamısını təmizlə @@ -266,6 +269,10 @@ {name} istifadəçisini silmək istədiyinizə əminsiniz? {name} göndərən yeni mesajlar mesaj tələbi olaraq gələcək. Hələ heç bir kontaktınız yoxdur Kontaktları seç + + %1$d Kontakt seçildi + %1$d Kontakt seçildi + İstifadəçi detalları Kamera Danışıq başlatmaq üçün bir fəaliyyət seçin @@ -306,6 +313,7 @@ Kopyala Yarat Zəng yaradılır + Hazırkı faktura Hazırkı parol Kəs Qaranlıq rejim @@ -333,6 +341,14 @@ Qrup yaradılarkən lütfən gözləyin... Qrup güncəlləmə uğursuz oldu Başqalarının mesajlarını silmə icazəniz yoxdur + + Seçilmiş qoşmanı sil + Seçilmiş qoşmaları sil + + + Seçilmiş qoşmanı silmək istədiyinizə əminsiniz? Qoşma ilə əlaqəli mesaj da silinəcək. + Seçilmiş qoşmaları silmək istədiyinizə əminsiniz? Qoşmalarla əlaqəli mesaj da silinəcək. + {name} adlı şəxsi kontaktlarınızdan silmək istədiyinizə əminsinizmi?\n\nBu, bütün mesajlar və qoşmalar daxil olmaqla söhbətinizi siləcək. {name} ünvanından gələcək mesajlar mesaj sorğusu kimi görünəcək. {name} ilə söhbətinizi silmək istədiyinizə əminsinizmi?\nBu, bütün mesajları və qoşmaları həmişəlik siləcək. @@ -448,17 +464,27 @@ Siz və {name}, {emoji_name} ilə reaksiya verdiniz Mesajınıza {emoji} reaksiyasını verdi Fəallaşdır + Kamera erişimi fəallaşdırılsın? Yeni mesaj aldığınız zaman bildirişlər göstərilsin. + Fəallaşdırmaq üçün zəngi söndür {app_name}-dan zövq alırsınız? Təkmilləşməlidir {emoji} Əladır {emoji} {app_name} tətbiqini bir müddətdir istifadə edirsiniz, necə gedir? Fikirlərinizi eşitmək bizim üçün çox dəyərli olardı. Daxil ol + {app_name} üçün təyin etdiyiniz parolu daxil edin + {pro} statusunu yoxlama xətası. Lütfən internet bağlantınızı yoxlayıb yenidən sınayın. Xətanı kopyala və çıx Veri bazası xətası Nəsə səhv getdi. Lütfən daha sonra yenidən sınayın. + {pro} erişimini yükləmə xətası + {app_name}, bu ONS-ni axtara bilmədi. Lütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. Bilinməyən bir xəta baş verdi. + Bu ONS qeydiyyatdan keçməyib. Lütfən doğru olub-olmadığını yoxlayıb yenidən sınayın. + {group_name} qrupundakı {name} üçün dəvət təkrar göndərilmədi + {group_name} qrupundakı {name}digər {count} nəfər üçün dəvət təkrar göndərilmədi + {group_name} qrupundakı {name}{other_name} üçün dəvət təkrar göndərilmədi Endirmək uğursuz oldu Xətalar Əks-əlaqə @@ -510,7 +536,6 @@ Siz qrupa qoşulmaq üçün dəvət edildiniz. Söhbət tarixçəsi paylaşıldı. Qrupu tərk et {group_name} qrupunu tərk etmək istədiyinizə əminsiniz? - {group_name} qrupunu tərk etmək istədiyinizə əminsiniz?\n\nBununla bütün üzvləri çıxarılacaq və qrupun bütün məzmunu silinəcək. {group_name} qrupunu tərk etmə uğursuz oldu {name} qrupu tərk etdi. {name}başqa {count} nəfər qrupu tərk etdi. @@ -541,23 +566,9 @@ Siz digər {count} nəfər Admin oldunuz. Siz{other_name} Admin oldunuz. {name} istifadəçisini {group_name} qrupundan çıxarmaq istəyirsiniz? - {name} və digər {count} nəfəri {group_name} qrupundan çıxartmaq istəyirsiniz? {name}{other_name} istifadəçilərini {group_name} qrupundan çıxarmaq istəyirsiniz? - - İstifadəçini və mesajlarını sil - İstifadəçiləri və mesajlarını sil - - - İstifadəçini sil - İstifadəçiləri sil - {name} qrupdan çıxarıldı. - {name}başqa {count} nəfər qrupdan çıxarıldı. {name}{other_name} qrupdan çıxarıldı. - {group_name} qrupundan çıxarıldınız. - Qrupdan çıxarıldınız. - Sizdigər {count} nəfər qrupdan çıxarıldınız. - Siz{other_name} qrupdan çıxarıldınız. Qrup ekran şəklini ayarla Bilinməyən Qrup Qrup güncəlləndi @@ -585,6 +596,10 @@ Mövcuddursa gizli rejimi tələb et. İstifadə etdiyiniz klaviaturadan asılı olaraq, klaviaturanız bu tələbi yox saya bilər. Məlumat Yararsız qısayol + + Kontaktı dəvət et + Kontaktları dəvət et + Dəvət uğursuz oldu Dəvətlər uğursuz oldu @@ -593,6 +608,10 @@ Dəvət göndərilə bilmədi. Təkrar cəhd etmək istəyirsiniz? Dəvətlər göndərilə bilmədi. Təkrar cəhd etmək istəyirsiniz? + + Üzvü dəvət et + Üzvləri dəvət et + Qoşul Daha sonra Kompüteriniz açıldığı zaman {app_name}-u avtomatik başlat. @@ -636,10 +655,15 @@ Kilidi açmaq üçün toxunun {app_name} kilidi açıldı Log-lar + Adminləri idarə et Üzvləri İdarə Et {pro} - idarə et Maksimum Media + + %1$d üzv seçildi + %1$d üzv seçildi + %1$d üzv %1$d üzv @@ -660,6 +684,7 @@ Mesaj tarixçəsini paylaş Yalnız yeni mesajları paylaş Dəvət et + Üzvlər (admin olmayanlar) Menyu çubuğu Mesaj Daha çox oxu @@ -678,6 +703,7 @@ Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini və ya ONS-sini daxil edin. Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini, ONS-sini daxil edin və ya onun QR kodunu skan edin. + Yeni bir danışıq başlatmaq üçün dostunuzun Hesab Kimliyini, ONS-sini daxil edin və ya onun QR kodunu skan edin {icon} Yeni bir mesajınız var. %1$d yeni mesajınız var. @@ -736,6 +762,8 @@ Ləqəb təyin et Xeyr Təklif yoxdur + Bütün danışıqlarda 10,000 xarakterə qədər mesaj göndərin. + Limitsiz sancılmış danışıqla söhbətləri təşkil edin. Heç biri İndi yox Özümə qeyd @@ -786,6 +814,7 @@ Yaxşı Açıq {device_type} cihazınızda + Başda qeydiyyatdan keçdiyiniz {platform_account} hesabına giriş etdiyiniz {device_type} cihazından bu {app_name} hesabını açın. Sonra {pro} erişiminizi {app_pro} ayarları vasitəsilə güncəlləyin. Hesab yarat Hesab yaradıldı Hesabım var @@ -810,6 +839,8 @@ Bu ONS-i tanıya bilmədik. Lütfən, yoxlayıb yenidən sınayın. Bu ONS-i axtara bilmədik. Lütfən, daha sonra yenidən sınayın. + {platform} veb saytını aç + Ayarları aç Anketi aç Digər Parol @@ -886,11 +917,28 @@ Tərcihlər Önizləmə Bildirişi önizlə + {pro} erişiminiz aktivdir!\n\n{pro} erişiminiz avtomatik olaraq başqa {current_plan_length} üçün {date} tarixində yenilənəcək. + {pro} erişiminizin müddəti {date} tarixində bitir.\n\n{pro} erişiminizin istifadə müddəti bitməzdən əvvəl avtomatik olaraq yenilənməsi üçün {pro} erişiminizi indi güncəlləyin. + {pro} erişiminiz aktivdir!\n\n{pro} erişiminiz avtomatik olaraq başqa \n{current_plan_length} üçün {date} tarixində yenilənəcək. Burada etdiyiniz istənilən dəyişiklik, növbəti yeniləmə zamanı qüvvəyə minəcək. + {pro} erişim xətası + {pro} erişiminizin istifadə müddəti {date} tarixində bitir. + {pro} erişimi yüklənir + {pro} erişimi yüklənir... + {pro} erişimi tapılmadı + {app_name}, hesabınızda {pro} erişimi olmadığını müəyyən etdi. Bunun bir səhv olduğunu düşünürsünüzsə, lütfən kömək üçün {app_name} dəstəyi ilə əlaqə saxlayın. + {pro} erişimini geri qaytar + {pro} erişimini yenilə + Hazırda, {pro} erişimi , yalnız {platform_store} və {platform_store_other} vasitəsilə satın alına və yenilənə bilər. {app_name} Masaüstü istifadə etdiyinizə görə burada yeniləyə bilməzsiniz.\n\n{app_name} gəlişdiriciləri, istifadəçilərin {pro} erişimini {platform_store} və {platform_store_other} xaricində almağına imkan verəcək alternativ ödəniş variantları üzərində ciddi şəkildə çalışırlar. {pro} Yol Xəritəsi {icon} + {pro} erişiminizi {pro} üçün qeydiyyatdan keçdiyiniz {platform_account} istifadə edərək {platform_store} veb saytında yeniləyin. + Güclü {pro} Beta özəlliklərini yenidən istifadə etməyə başlamaq üçün {app_pro} erişiminizi yeniləyin. + {pro} erişimi geri qaytarıldı + {app_name}, hesabınız üçün {pro} erişimini aşkarladı və geri qaytardı. {pro} statusunuz bərpa edildi! + Başlanğıcda {platform_store} üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, {pro} erişiminizi {platform_account} istifadə edərək güncəlləməlisiniz. Aktivləşdirildi Hər şey hazırdır! + {app_pro} erişiminiz güncəllənib! {pro} abunəliyiniz {date} tarixində avtomatik yeniləndiyi zaman ödəniş haqqı alınacaq. Artıq yüksəltdiniz Getdik və ekran şəkliniz üçün GIF-lər və animasiyalı WebP təsvirləri yükləyin! - {app_pro} ilə animasiyalı ekran şəkillərini endirin və premium özəlliklərin kilidini açın Animasiyalı profil şəkli istifadəçiləri GIF-ləri yükləyə bilər Animasiyalı ekran şəkilləri @@ -909,11 +957,15 @@ {price} - illik haqq {price} - aylıq haqq {price} - rüblük haqq - Daha uzun mesajlar göndərmək istəyirsiniz? {app_pro} ilə daha çox mətn göndərin və premium özəlliklərin kilidini açın Daha çoxunu sancmaq istəyirsiniz? {app_pro} ilə söhbətlərinizi təşkil edin və premium özəlliklərin kilidini açın + Ləğv etmə + {pro} erişiminizi ləğv etməyin iki yolu var: + Tam {app_pro} qiymətinə sahib {pro} erişiminiz üçün artıq {percent}% endirim var. {pro} statusunu təzələmə xətası Müddəti bitib + Təəssüf ki, {pro} erişiminizin istifadə müddəti bitib. {app_pro} üzrə eksklüziv imtiyazları və özəllikləri təkrar aktivləşdirmək üçün yeniləyin. Tezliklə bitir + {pro} planınızın istifadə müddəti {time} vaxtında bitir. {app_pro} üzrə eksklüziv imtiyazlara və özəlliklərə erişimi davam etdirmək üçün planınızı güncəlləyin. {pro}, {time} vaxtında başa çatır {pro} TVS {app_pro} TVS-da tez-tez verilən suallara cavab tapın. @@ -928,10 +980,12 @@ %1$s qrup yüksəldildi %1$s qrup yüksəldildi + Geri ödəniş tələbi qətidir. Əgər təsdiqlənsə, {pro} erişiminiz dərhal ləğv ediləcək və bütün {pro} özəlliklərinə erişimi itirəcəksiniz. Artırılmış qoşma ölçüsü Artırılmış mesaj uzunluğu Daha böyük qruplar Admin olduğunuz qruplar, avtomatik olaraq 300 üzvü dəstəkləmək üçün təkmilləşdirilir. + Tezliklə bütün Pro Beta istifadəçiləri üçün daha böyük qrup söhbətləri (300 üzvə qədər) gəlir! Daha uzun mesajlar Bütün danışıqlarda 10,000 xarakterə qədər mesaj göndərə bilərsiniz. @@ -939,17 +993,28 @@ %1$s daha uzun mesaj göndərildi Bu mesajda aşağıdakı {app_pro} özəllikləri istifadə olunub: + İndilik, yeniləməyin iki yolu var: {percent}% endirim %1$s sancılmış danışıq %1$s sancılmış danışıq + Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri ödəmə tələbini göndərmək üçün eyni {platform_account} hesabını istifadə etməlisiniz. + Başda {platform_store} Mağazası üzərindən {app_pro} üçün qeydiyyatdan keçdiyinizə görə, geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəmə tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəmə tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. + {app_pro} erişiminiz yeniləndi! {network_name} dəstək verdiyiniz üçün təşəkkürlər. 1 ay - {monthly_price}/ay 3 ay - {monthly_price}/ay 12 ay - {monthly_price}/ay Getməyinizə məyus olduq. Geri ödəmə tələb etməzdən əvvəl bilməli olduğunuz şeylər. + {platform} hazırda geri ödəniş tələbinizi emal edir. Bu, adətən 24-48 saat çəkir. Onların qərarından asılı olaraq, {app_name} tətbiqində {pro} statusunuzun dəyişdiyini görə bilərsiniz. Geri qaytarma tələbiniz {app_name} Dəstək komandası tərəfindən icra olunacaq.\n\nAşağıdakı düyməyə basaraq və geri ödəniş formunu dolduraraq geri ödəniş tələbinizi göndərin.\n\n{app_name} Dəstək komandası, geri ödəniş tələblərini adətən 24-72 saat ərzində emal edir, yüksək tələb həcminə görə bu proses daha uzun çəkə bilər. + Geri ödəniş tələbiniz yalnız {platform} veb saytında {platform} hesabı üzərindən icra olunacaq.\n\n{platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. + Geri ödəmə tələbinizlə bağlı daha çox güncəlləmə üçün lütfən {platform} ilə əlaqə saxlayın. {platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz.\n\n{platform} Geri ödəmə dəstəyi {pro} geri ödəməsi + {app_pro} üçün geri ödəmələr yalnız {platform_store} vasitəsilə {platform} tərəfindən həyata keçirilir.\n\n{platform} geri ödəniş siyasətlərinə əsasən, {app_name} gəlişdiriciləri, geri ödəniş tələblərinin nəticəsinə təsir edə bilməz. Bu, tələbin qəbul olunub-olunmaması ilə yanaşı, tam və ya qismən geri ödənişin verilib-verilməməsini də əhatə edir. + {pro} Beta-nı yenilə + {pro} erişiminizi, {platform_store} və ya {platform_store_other} üzərindən {app_name} quraşdırılmış və əlaqələndirilmiş cihazın {app_pro} ayarlarında yeniləyin. + Pro yeniləməsi uğursuz oldu, tezliklə yenidən sınanacaq Geri ödəmə tələb edildi Daha çoxunu göndərmək üçün {pro} ayarları @@ -964,10 +1029,12 @@ {pro} status yüklənir {pro} statusunuzu yoxlamaq üçün şəbəkəyə bağlana bilmir. Bağlantı bərpa olunana qədər {pro} ya yüksəldə bilməyəcəksiniz.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. {pro} statusunuzu təzələmək üçün şəbəkəyə bağlana bilmir. Bu səhifədəki bəzi əməliyyatlar, bağlantı bərpa olunana qədər sıradan çıxarılacaq.\n\nLütfən şəbəkə bağlantınızı yoxlayıb yenidən sınayın. + {pro} ilə bağlı kömək lazımdır? Dəstək komandasına sorğu göndər. Güncəlləyərək, {app_pro} Xidmət Şərtləri {icon} və Məxfilik Siyasəti {icon} ilə razılaşırsınız Limitsiz sancma Limitsiz sancılmış danışıqla bütün söhbətlərinizi təşkil edin. {app_name} tətbiqindən daha çox faydalanmaq istəyirsiniz? Daha güclü mesajlaşma təcrübəsi üçün {app_pro}-ya yüksəldin. + {platform} geri ödəmə tələbinizi emal edir Profil Ekran şəkli Ekran şəklini silmə uğursuz oldu. @@ -975,6 +1042,10 @@ Lütfən daha kiçik bir fayl götürün. Profili güncəlləmək uğursuz oldu. Yüksəlt + + Üzvü yüksəlt + Üzvləri yüksəlt + Yüksəltmə uğursuz oldu Yüksəltmələr uğursuz oldu @@ -1023,18 +1094,43 @@ Bu, sizin geri qaytarma parolunuzdur. Kiməsə göndərsəniz, hesabınıza tam erişə bilər. Qrupu yenidən yarat Təkrar et + Başda fərqli {platform_account} vasitəsilə {app_pro} üçün qeydiyyatdan keçdiyinizə görə planınızı +{pro} erişiminizi güncəlləmək üçün həmin {platform_account} platformasını istifadə etməlisiniz. + Geri qaytarma tələb etmək üçün iki üsul: Mesajın uzunluğunu {count} qədər azalt %1$d xarakter qaldı %1$d xarakter qaldı + Daha sonra xatırlat Sil Parol silmə uğursuz oldu {app_name} üçün hazırkı parolunuzu silin. Daxili olaraq saxlanılmış verilər, cihazınızda saxlanılan təsadüfi yaradılmış açarla təkrar şifrələnəcək. + + Üzv çıxarılır + Üzvlər çıxarılır + Yenilə + {pro} yeniləmə Cavabla Geri ödəmə tələb et Təkrar göndər + + Dəvəti təkrar göndər + Dəvətləri təkrar göndər + + + Yüksəltməni təkrar göndər + Yüksəltmələri təkrar göndər + + + Dəvətlər təkrar göndərilir + Dəvət təkrar göndərilir + + + Yüksəltmə təkrar göndərilir + Yüksəltmələr təkrar göndərilir + Ölkə məlumatları yüklənir... Yenidən başlat Təkrar sinxronlaşdır @@ -1135,6 +1231,9 @@ Geri al Bilinmir Dəstəklənməyən CPU + Güncəllə + {pro} erişimini güncəllə + {pro} erişiminizi güncəlləməyin iki yolu var: Tətbiq güncəlləmələri İcma məlumatlarını güncəllə İcma adı və açıqlaması, bütün icma üzvlərinə görünür @@ -1157,6 +1256,8 @@ Son güncəlləmə: {relative_time} əvvəl Güncəlləmələr Güncəllənir... + Yüksəlt + {app_name} - yüksəlt Yüksəlt Yüklənir URL-ni kopyala @@ -1165,6 +1266,8 @@ Bu URL-ni brauzerinizdə açmaq istədiyinizə əminsiniz?\n\n{url} Keçidlər, brauzerinizdə açılacaq. Sürətli rejimi istifadə et + {platform} veb saytı vasitəsilə + Qeydiyyatdan keçdiyiniz {platform_account} hesabını istifadə edərək {pro} erişiminizi {platform_store} veb saytı üzərindən güncəlləyin. Video Video oxudula bilmir. Göstər diff --git a/app/src/main/res/values-b+ca+ES/strings.xml b/app/src/main/res/values-b+ca+ES/strings.xml index b3b587a465..ce9a1a721b 100644 --- a/app/src/main/res/values-b+ca+ES/strings.xml +++ b/app/src/main/res/values-b+ca+ES/strings.xml @@ -820,11 +820,9 @@ Vista prèvia Ja ho tens Endavant i penja GIFs i imatges del webp animat per a la teva imatge de visualització! - Obteniu imatges de visualització animada i desbloquegeu funcions premium amb {app_pro} Imatge de pantalla animada els usuaris poden penjar GIFs Penja els gifs amb - Voleu enviar missatges més llargs? Envia més text i desbloqueja funcions premium amb {app_pro} Vols més pins? Organitzes els teus xats i desbloqueges les funcions premium amb {app_pro} Xateja de grups més grans fins a 300 membres Plus carrega funcions més exclusives diff --git a/app/src/main/res/values-b+cs+CZ/strings.xml b/app/src/main/res/values-b+cs+CZ/strings.xml index bbad3090df..043e35b03d 100644 --- a/app/src/main/res/values-b+cs+CZ/strings.xml +++ b/app/src/main/res/values-b+cs+CZ/strings.xml @@ -1,5 +1,6 @@ + V této skupině nejsou žádní členové, kromě správců. Info Přijmout Kopírovat ID účtu @@ -15,8 +16,15 @@ Toto je vaše ID účtu. Ostatní uživatelé jej mohou naskenovat, aby s vámi mohli zahájit konverzaci. Skutečná velikost Přidat + + Přidat správce + Přidat správce + Přidat správce + Přidat správce + Přidat správce Zadejte ID účtu uživatele, kterého povyšujete na správce.\n\nChcete-li přidat více uživatelů, zadejte každé ID účtu oddělené čárkou. Najednou lze zadat až 20 ID účtů. + Správci nemohou být poníženi ani odebráni ze skupiny. Správce nelze odebrat. {name} a {count} dalších byli povýšeni na správce. Povýšit na správce @@ -41,6 +49,12 @@ {name} byl/a odebrán/a jako správce. {name} a {count} dalším bylo odebráno správcovství. {name} a {other_name} bylo odebráno správcovství. + + %1$d správce vybrán + %1$d správcové vybráni + %1$d správců vybráno + %1$d správců vybráno + Odesílání povýšení na správce Odesílání povýšení na správce @@ -48,7 +62,9 @@ Odesílání povýšení na správce Nastavení správce + Nemůžete změnit svůj stav správce. Chcete-li skupinu opustit, otevřete nastavení konverzace a vyberte možnost Opustit skupinu. {name} a {other_name} byli povýšeni na správce. + Správci +{count} Anonymní Ikona aplikace @@ -193,6 +209,10 @@ Povolí hlasové a video hovory k ostatním uživatelům i od nich. Volali jste {name} Zmeškali jste hovor od {name}, protože jste neměli povoleny Hlasové a video hovory v nastavení ochrany soukromí. + Aplikace {app_name} vyžaduje přístup k vašemu fotoaparátu, aby mohla provádět videohovory, ale toto oprávnění bylo zamítnuto. Během hovoru nelze aktualizovat oprávnění k fotoaparátu.\n\nChcete nyní ukončit hovor a povolit přístup k fotoaparátu, nebo chcete, aby vám to bylo připomenuto po skončení hovoru? + Chcete-li povolit přístup k fotoaparátu, otevřete nastavení a povolte oprávnění k fotoaparátu. + Během posledního hovoru jste se pokusili použít video, ale nebylo to možné, protože přístup k fotoaparátu byl dříve zamítnut. Chcete-li povolit přístup k fotoaparátu, otevřete nastavení a povolte oprávnění k fotoaparátu. + Je vyžadován přístup k fotoaparátu Nebyla nalezena žádná kamera Kamera není dostupná. Povolit přístup k fotoaparátu @@ -200,10 +220,18 @@ {app_name} potřebuje přístup k fotoaparátu pro pořizování fotografií a videí nebo skenování QR kódů. {app_name} potřebuje přístup k fotoaparátu ke skenování QR kódů Zrušit + Zrušit {pro} + Zrušit tarif {pro} + Zrušení proveďte na webu {platform} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Zrušení proveďte na webu {platform_store} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. Změnit Změna hesla selhala Změňte své heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí vašeho nového hesla. Kontrola stavu {pro} + Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci pokračovat. + Kontrolují se vaše údaje {pro}. Některé akce na této stránce nemusí být dostupné, dokud kontrola nebude dokončena. + Kontrola stavu {pro}... + Kontrolují se podrobnosti vašeho {pro}. Dokud tato kontrola není dokončena, není možné prodloužení. Kontroluje se váš stav {pro}. Jakmile kontrola skončí, budete moci navýšit na {pro}. Smazat Smazat vše @@ -265,11 +293,19 @@ Adresa komunity Kopírovat adresu komunity Potvrdit + Potvrdit povýšení + Jste si jistí? Správci nemohou být ponížení ani odebráni ze skupiny. Kontakty Smazat kontakt Jste si jisti, že chcete smazat {name} ze svých kontaktů? Nové zprávy od {name} budou doručeny jako žádosti o komunikaci. Zatím nemáte žádné kontakty Vybrat kontakty + + %1$d kontakt vybráno + %1$d kontakty vybrány + %1$d kontaktů vybráno + %1$d kontaktů vybráno + Podrobnosti uživatele Kamera Zvolte akci pro zahájení konverzace @@ -310,6 +346,7 @@ Kopírovat Vytvořit Vytváření hovoru + Současné fakturování Aktuální heslo Vyjmout Tmavý režim @@ -337,6 +374,18 @@ Počkejte prosím, než se skupina vytvoří... Aktualizace skupiny selhala Nemáte oprávnění k mazání zpráv ostatních + + Smazat vybranou přílohu + Smazat vybrané přílohy + Smazat vybrané přílohy + Smazat vybrané přílohy + + + Opravdu chcete smazat vybranou přílohu? Zpráva přidružená k této příloze bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat vybrané přílohy? Zpráva přidružená k těmto přílohám bude také smazána. + Opravdu chcete smazat {name} z vašich kontaktů?\n\nTím smažete vaše konverzace, včetně všech zpráv a příloh. Budoucí zprávy od {name} se zobrazí jako žádost o komunikaci. Opracdu chcete smazat konverzaci s {name}?\nTím trvale smažete všechny zprávy a přílohy. @@ -468,17 +517,28 @@ Vy a {name} reagovali {emoji_name} Reagoval(a) na vaši zprávu {emoji} Povolit + Povolit přístup k fotoaparátu? Zobrazit upozornění při přijetí nových zpráv. + Pro povolení ukončit hovor Líbí se vám {app_name}? Potřebuje vylepšit {emoji} Skvělé {emoji} Používáte {app_name} \na rádi bychom znali váš názor. Vstoupit + Zadejte heslo, které jste nastavili pro {app_name} + Zadejte heslo, které používáte k odemknutí {app_name} při spuštění, nikoli heslo pro obnovení + Chyba kontroly stavu {pro} Zkontrolujte prosím připojení k internetu a zkuste to znovu. Zkopírovat chybu a ukončit Chyba databáze Něco se pokazilo. Zkuste to prosím později. + Chyba načítání přístupu k {pro} + {app_name} nemohla vyhledat tento ONS. Zkontrolujte prosím připojení k internetu a zkuste to znovu. Došlo k neznámé chybě. + Tento ONS není registrován. Zkontrolujte prosím jeho správnost a zkuste to znovu. + Nepodařilo se znovu odeslat pozvánku pro {name} ve skupině {group_name} + Nepodařilo se znovu odeslat pozvánku pro {name} a {count} dalších ve skupině {group_name} + Nepodařilo se znovu odeslat pozvánku pro {name} a {other_name} ve skupině {group_name} Stahování selhalo Chyby Zpětná vazba @@ -534,6 +594,9 @@ Opravdu chcete opustit {group_name}? Jste si jisti, že chcete opustit {group_name}?\n\nTímto odeberete všechny členy a smažete veškerý obsah skupiny. Selhalo odhlášení ze skupiny {group_name} + {name} byl(a) pozván(a) do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. + {name} a {count} další byli pozváni do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. + {name} a {other_name} byli pozváni do skupiny. Byla sdílena i historie konverzací skupiny za posledních 14 dní. {name} opustil(a) skupinu. {name} a {count} dalších opustilo skupinu. {name} a {other_name} opustili skupinu. @@ -545,6 +608,9 @@ {name} a {other_name} byli pozváni do skupiny. Vy a {count} dalších bylo pozváno do skupiny. Historie konverzace byla sdílena. Vy a {other_name} jste byli pozvání do skupiny. Historie konverzace byla sdílena. + Nepodařilo se odebrat {name} z {group_name} + Nepodařilo se odebrat {name} a {count} dalších z {group_name} + Nepodařilo se odebrat {name} a {other_name} z {group_name} Opustil/a jste skupinu. Členové skupiny V této skupině nejsou žádní další členové. @@ -558,6 +624,7 @@ Nemáte žádné zprávy od {group_name}. Pošlete zprávu pro zahájení konverzace! Tato skupina nebyla aktualizována déle než 30 dní. Může dojít k problémům s odesíláním zpráv nebo zobrazováním informací o skupině. Jste jediný správce ve skupině {group_name}.\n\nČlenové skupiny a nastavení nelze změnit bez správce. + Jste jediný správce skupiny {group_name}.\n\nČlenové skupiny a nastavení nelze změnit bez správce. Pokud chcete skupinu opustit bez jejího smazání, nejprve přidejte nového správce Čeká na odebrání Byli jste povýšeni na správce. Vy a {count} dalších jste byli povýšeni na správce. @@ -565,6 +632,18 @@ Chcete odstranit {name} ze skupiny {group_name}? Chcete odstranit {name} a {count} dalších ze skupiny {group_name}? Chcete odstranit {name} a {other_name} ze skupiny {group_name}? + + Odebrat uživatele a jeho zprávy + Odebrat uživatele a jejich zprávy + Odebrat uživatele a jejich zprávy + Odebrat uživatele a jejich zprávy + + + Odebrat uživatele + Odebrat uživatele + Odebrat uživatele + Odebrat uživatele + {name} byl odebrán ze skupiny. {name} a {count} dalších byli odebráni ze skupiny. {name} a {other_name} byli odebráni ze skupiny. @@ -599,6 +678,12 @@ Požadovat anonymní režim, pokud je k dispozici. V závislosti na klávesnici, kterou používáte, může být tento požadavek ignorován. Info Chybná zkratka + + Pozvat kontakt + Pozvat kontakty + Pozvat kontakty + Pozvat kontakty + Pozvání selhalo Pozvání selhala @@ -611,6 +696,13 @@ Pozvánky nemohly být odeslány. Chcete to zkusit znovu? Pozvánky nemohly být odeslány. Chcete to zkusit znovu? + + Pozvat člena + Pozvat členy + Pozvat členy + Pozvat členy + + Pozvěte nového člena do skupiny zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu {icon} Připojit Později Spustit {app_name} automaticky při spuštění počítače. @@ -654,10 +746,17 @@ Klepněte pro odemčení {app_name} je odemčena Logy + Spravovat správce Spravovat členy Spravovat {pro} Maximum Média + + %1$d člen vybrán + %1$d členové vybráni + %1$d členů vybráno + %1$d členů vybráno + %1$d člen %1$d členové @@ -671,7 +770,9 @@ %1$d aktivních členů Přidat ID účtu nebo ONS + Členové mohou být povýšeni teprve poté,&#13; co přijmou pozvání ke vstupu do skupiny. Pozvat kontakty + Nemáte žádné kontakty, které byste mohli pozvat do této skupiny.\nVraťte se zpět později a pozvěte členy pomocí jejich ID účtu nebo ONS. Odeslat pozvánku Odeslat pozvánky @@ -682,8 +783,11 @@ Chcete sdílet historii zpráv skupiny s {name} a {count} dalšími? Chcete sdílet historii zpráv skupiny s {name} a {other_name}? Sdílet historii zpráv + Sdílet historii zpráv za posledních&#13; +14 dní Sdílet pouze nové zprávy Pozvat + Členové (ne správci) Panel menu Zpráva Více @@ -704,6 +808,7 @@ Zahajte novou konverzaci zadáním ID účtu vašeho přítele nebo ONS. Zahajte novou konverzaci zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu. + Zahajte novou konverzaci zadáním ID účtu vašeho přítele, ONS nebo naskenováním jejich QR kódu {icon} Máte novou zprávu. Máte %1$d nové zprávy. @@ -768,6 +873,8 @@ Nastavit přezdívku Ne Žádné návrhy + Ve všech konverzacích můžete odesílat zprávy o délce až 10000 znaků. + Organizujte komunikace pomocí neomezeného počtu připnutých konverzací. Nic Teď ne Poznámka sobě @@ -818,6 +925,11 @@ OK Zap. Na vašem zařízení {device_type} + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do účtu {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté zrušte {pro} v nastavení {app_pro}. + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno do {platform_account}, pomocí kterého jste se původně zaregistrovali. Poté aktualizujte svůj přístup {pro} v nastavení {app_pro}. + Na propojeném zařízení + Na webových stránkách {platform_store} + Na webových stránkách {platform} Vytvořit účet Účet vytvořen Mám účet @@ -842,6 +954,9 @@ Nepodařilo se rozpoznat tento ONS. Zkontrolujte ho a zkuste to znovu. Nepodařilo se vyhledat tento ONS. Zkuste to prosím později. Otevřít + Otevřete webovou stránku {platform_store} + Otevřete webovou stránku {platform} + Otevřít nastavení Otevřít dotazník Ostatní Heslo @@ -918,16 +1033,38 @@ Předvolby Náhled Náhled upozornění + Váš přístup k {pro} je aktivní!\n\nPřístup k {pro} bude automaticky prodloužen na další {current_plan_length} dne {date}. + Váš přístup k {pro} vyprší dne {date}.\n\nAktualizujte si přístup k {pro} nyní, abyste zajistili automatické prodloužení před vypršením {pro}. + Váš přístup k {pro} je aktivní!\n\nVáš přístup k {pro} bude automaticky prodloužen na další\n{current_plan_length} dne {date}. Veškeré změny, které zde provedete, se projeví při dalším prodloužení. + Chyba přístupu k {pro} + Váš přístup k {pro} vyprší dne {date}. + Načítání přístupu k {pro} + Informace o vašem přístupu k {pro} se stále načítají. Dokud nebude tento proces dokončen, nemůžete provézt aktualizaci. + Načítání přístupu k {pro}... + Nelze se připojit k síti aby byly načteny informace o vašem přístupu k {pro}. Aktualizace {pro} prostřednictvím {app_name} nebude aktivní, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. + Přístup k {pro} nebyl nalezen + Aplikace {app_name} zjistila, že váš účet nemá přístup k {pro}. Pokud si myslíte, že se jedná o chybu, kontaktujte podporu {app_name} a požádejte o pomoc. + Obnovit přístup k {pro} + Prodloužit přístup k {pro} + V současnosti lze přístup k {pro} zakoupit a prodloužit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože používáte {app_name} Desktop, nemůžete zde provést prodloužení.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} + Prodlužte si přístup k {pro} na webu {platform_store} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Prodlužte na webu {platform} pomocí {platform_account}, kterým jste si zaregistrovali {pro}. + Prodlužte svůj přístup k {pro}, abyste mohli znovu používat výkonné funkce {app_pro} Beta. + Přístup k {pro} obnoven + Aplikace {app_name} rozpoznala a obnovila vašemu účtu přístup k {pro}. Váš stav {pro} byl obnoven! + Protože jste si původně zaregistrovali {app_pro} přes {platform_store}, je třeba, abyste k aktualizaci svého přístupu k {pro} využili svůj {platform_account}. + V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože používáte {app_name} Desktop, nemůžete zde provést navýšení na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Aktivováno Vše je nastaveno! + Váš přístup k {app_pro} byl aktualizován! Účtování proběhne při automatickém prodloužení {pro} dne {date}. Už máte Jako váš zobrazovaný profilový obrázek nastavte animovaný GIF nebo WebP! - Získejte možnost nahrát animovaný zobrazovaný obrázek profilu a další prémiové funkce se Session Pro Animovaný zobrazovaný obrázek uživatelé mohou nahrávat GIFy Animované zobrazované obrázky Nastavte si animované obrázky GIF a WebP jako svůj zobrazovaný profilový obrázek. Nahrajte GIFy se + {pro} bude automaticky prodlouženo za {time} Odznak {pro} Zobrazit odznak {app_pro} ostatním uživatelům Odznaky @@ -942,12 +1079,21 @@ {price} účtováno ročně {price} účtováno měsíčně {price} účtováno čtvrtletně - Chcete posílat delší zprávy? Posílejte více textu odemknutím prémiových funkcí Session Pro Chcete více připnutí? Organizujte své chaty a odemkněte prémiové funkce pomocí Session Pro + Mrzí nás, že rušíte {pro}. Než zrušíte {pro}, přečtěte si, co byste měli vědět. Zrušit + Zrušení přístupu k {pro} zabrání jeho automatickému prodloužení před vypršením platnosti přístupu k {pro}. Zrušením {pro} nedojde k vrácení peněz. Funkce {app_pro} budete moci využívat až do vypršení přístupu k {pro}.\n\nProtože jste si původně {app_pro} zařídili pomocí účtu {platform_account}, bude ke zrušení přístupu k {pro} potřeba použít stejný {platform_account}. + Dva způsoby, jak zrušit váš přístup k {pro}: + Zrušením přístupu k {pro} zabráníte jeho automatickému prodloužení předtím, než {pro} vyprší.\n\nZrušení {pro} neznamená vrácení peněz. Funkce {app_pro} můžete používat až do vypršení přístupu {pro}. + Vyberte si možnost přístupu k {pro}, která vám vyhovuje nejlépe.\nDelší přístup znamená vyšší slevu. + Opravdu chcete smazat svá data z tohoto zařízení?\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj přístup k {pro} později obnovit. + Opravdu chcete smazat svá data ze sítě? Pokud budete pokračovat, nebudete moci obnovit vaše zprávy ani kontakty.\n\n{app_pro} nelze převést na jiný účet. Uložte si své heslo pro obnovení, abyste mohli svůj přístup k {pro} později obnovit. + Váš přístup k {pro} je již zlevněn o {percent} % z plné ceny {app_pro}. Chyba obnovování stavu {pro} Platnost vypršela + Bohužel, váš tarif {pro} vypršel. Prodlužte jej, abyste znovu získali přístup k exkluzivním výhodám a funkcím {app_pro}. Brzy vyprší + Váš přístup k {pro} vyprší za {time}. Proveďte nyní aktualizaci, abyste si zachovali přístup k exkluzivním výhodám a funkcím {app_pro}. {pro} vyprší za {time} {pro} FAQ Najděte odpovědi na časté dotazy v nápovědě {app_pro}. @@ -956,6 +1102,7 @@ A další exkluzivních funkce Zprávy až do 10000 znaků Připněte neomezený počet konverzací + Chcete využít potenciál {app_name} naplno?\nPřejděte na {app_pro} Beta a získejte přístup k mnoha exkluzivním výhodám a funkcím. Skupina aktivována Tato skupina má navýšenou kapacitu! Může podporovat až 300 členů, protože správce skupiny má @@ -964,6 +1111,7 @@ %1$s skupin navýšeno %1$s skupin navýšeno + Žádost o vrácení peněz je konečná. Pokud bude schválena, váš přístup k {pro} bude ihned zrušen a ztratíte přístup ke všem funkcím {pro}. Navýšená velikost přílohy Navýšená délka zprávy Větší skupiny @@ -978,6 +1126,11 @@ %1$s delších zpráv odesláno V této zprávě byly použity následující funkce {app_pro}: + Pomocí nové instalace + Znovu nainstalujte {app_name} na tomto zařízení prostřednictvím {platform_store}, obnovte svůj účet pomocí hesla pro obnovení a prodlužte {pro} v nastavení {app_pro}. + Znovu nainstalujte {app_name} na tomto zařízení prostřednictvím {platform_store}, obnovte svůj účet pomocí hesla pro obnovení a navyšte na {pro} v nastavení {app_pro}. + Nyní jsou k dispozici tři způsoby prodloužení: + Nyní jsou k dispozici dva způsoby prodloužení: Sleva {percent} % %1$s připnutá konverzace @@ -985,15 +1138,33 @@ %1$s připnutých konverzací %1$s připnutých konverzací + Protože jste se původně zaregistrovali do {app_pro} přes obchod {platform_store}, budete muset pro žádost o vrácení peněz použít stejný účet {platform_account}. + Protože jste si původně zakoupili {app_pro} přes obchod {platform_store}, váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24-72 hodin, může zpracování trvat i déle, pokud dochází k vysokému počtu žádostí. + Váš přístup k {app_pro} byl prodloužen! Děkujeme, že podporujete síť {network_name}. 1 měsíc – {monthly_price} / měsíc 3 měsíce – {monthly_price} / měsíc 12 měsíců – {monthly_price} / měsíc + Otevřete tento účet {app_name} na zařízení {device_type}, které je přihlášeno k účtu {platform_account}, se kterým jste se původně zaregistrovali. Poté požádejte o vrácení platby přes nastavení {app_pro}. Mrzí nás, že to rušíte. Než požádáte o vrácení peněz, přečtěte si informace, které byste měli vědět. + {platform} nyní zpracovává vaši žádost o vrácení peněz. Obvykle to trvá 24-48 hodin. V závislosti na jejich rozhodnutí se může váš stav {pro} v aplikaci {app_name} změnit. Váš požadavek na vrácení peněz bude zpracován podporou {app_name}.\n\nPožádejte o vrácení peněz kliknutím na tlačítko níže a vyplněním formuláře žádosti o vrácení peněz.\n\nAčkoliv se podpora {app_name} snaží zpracovat žádosti o vrácení peněz během 24–72 hodin, může zpracování trvat i déle, v případě že je vyřizováno mnoho žádostí. + Vaši žádost o vrácení peněz bude vyřizovat výhradně {platform} prostřednictvím webu {platform}.\n\nVzhledem k pravidlům vracení peněz {platform} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. + Pro další informace o vaší žádosti o vrácení peněz kontaktujte prosím {platform}. Vzhledem k zásadám pro vrácení peněz {platform} nemají vývojáři aplikace {app_name} žádnou možnost ovlivnit výsledek žádosti o vrácení.\n\nPodpora vrácení peněz {platform} Vracení peněz za {pro} + Vrácení peněz za {app_pro} je vyřizováno výhradně prostřednictvím {platform} v obchodě {platform_store}.\n\nVzhledem k pravidlům vracení peněz služby {platform} nemají vývojáři {app_name} žádný vliv na výsledek žádostí o vrácení peněz. To zahrnuje i rozhodnutí, zda bude žádost schválena nebo zamítnuta, a také, zda bude vrácena část peněz, nebo všechny peníze. + Chcete znovu používat animované profilové obrázky?\nObnovte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Prodloužit {pro} Beta + Prodlužte svůj přístup k {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou aplikací {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. + Chcete znovu posílat delší zprávy?\nObnovte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Chcete znovu využít {app_name} naplno?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Chcete si znovu připnout více konverzací?\nProdlužte si přístup k {pro} pro odemknutí funkcí, které vám chyběly. + Prodloužením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} + Prodloužení Pro selhalo, brzy proběhne další pokus + V současnosti lze přístup k {pro} zakoupit a prodloužit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde prodloužit přístup.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} Žádost o vrácení peněz Posílejte více se Nastavení {pro} + Začít používat {pro} Vaše statistiky {pro} Načítání statistik {pro} Vaše statistiky {pro} se načítají, počkejte prosím. @@ -1003,12 +1174,26 @@ Stav načítání {pro} Načítají se vaše informace {pro}. Některé akce na této stránce nemusí být dostupné, dokud nebude načítání dokončeno. stav načítání {pro} + Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze pokračovat.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby bylo možné zkontrolovat váš stav {pro}. Dokud nebude obnoveno připojení, nelze provést navýšení na {pro}.\n\nZkontrolujte připojení k síti a zkuste to znovu. Nelze se připojit k síti, aby se obnovil váš stav {pro}. Některé akce na této stránce budou deaktivovány, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. + Nelze se připojit k síti kvůli načtení vašeho aktuálního přístupu k {pro}. Obnovení {pro} prostřednictvím {app_name} bude deaktivováno, dokud nebude obnoveno připojení.\n\nZkontrolujte připojení k síti a zkuste to znovu. + Potřebujete pomoc s {pro}? Pošlete žádost týmu podpory. Aktualizací souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Neomezený počet připnutí Organizujte si komunikaci pomocí neomezeného počtu připnutých konverzací. + Vaše aktuální fakturační možnost zahrnuje {current_plan_length} přístupu k {pro}. Jste si jisti, že chcete přejít na fakturační možnost {selected_plan_length_singular}?\n\nPo aktualizaci se váš přístup k {pro} automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. + Váš přístup k {pro} vyprší {date}.\n\nAktualizací se váš {pro} přístup automaticky prodlouží {date} na dalších {selected_plan_length} přístupu k {pro}. + Navyšte na {app_pro} Beta a získejte přístup k mnoha exkluzivním výhodám a funkcím. + Navyšte na {pro} v nastavení {app_pro} na propojeném zařízení s nainstalovanou {app_name} prostřednictvím {platform_store} nebo {platform_store_other}. + V současnosti lze přístup k {pro} zakoupit pouze prostřednictvím {platform_store} nebo {platform_store_other}. Protože jste nainstalovali {app_name} pomocí {build_variant}, nemůžete zde navýšit na {pro}.\n\nVývojáři {app_name} intenzivně pracují na alternativních platebních možnostech, které by uživatelům umožnily zakoupit přístup k {pro} mimo {platform_store} a {platform_store_other}. Plán vývoje {pro} {icon} + V tuto chvíli je k dispozici pouze jedna možnost navýšení: + Nyní jsou k dispozici dva způsoby navýšení: + Navýšili jste na {app_pro}!\n\nDěkujeme, že podporujete síť {network_name}. + Navýšení na {pro} + Navýšením souhlasíte s Podmínkami služby {icon} a Zásadami ochrany osobních údajů {icon} {app_pro} Chcete z {app_name} získat více? Navyštee na {app_pro} pro výkonnější posílání zpráv. + {platform} zpracovává vaši žádost o vrácení peněz Profil Zobrazovaný obrázek Chyba při odstraňování zobrazovaného obrázku. @@ -1016,6 +1201,13 @@ Prosím vyberte menší soubor. Nepodařilo se aktualizovat profil. Povýšit + Správci budou moci vidět historii zpráv za posledních 14 dní a nemohou být ponížení ani odebráni ze skupiny. + + Povýšit člena + Povýšit členy + Povýšit členy + Povýšit členy + Povýšení selhalo Povýšení selhala @@ -1068,6 +1260,8 @@ Toto je vaše heslo pro obnovení. Pokud ho někomu pošlete, bude mít plný přístup k vašemu účtu. Znovu vytvořit skupinu Znovu + Protože jste si původně zaregistrovali {app_pro} přes jiný {platform_account}, je třeba použít ten {platform_account}, abyste aktualizovali svůj přístup k {pro}. + Dva způsoby, jak požádat o vrácení platby: Zkraťte délku zprávy o {count} Zbývá %1$d znak @@ -1075,12 +1269,58 @@ Zbývá %1$d znaků Zbývá %1$d znaků + Připomenout později Odstranit + + Odebrat člena + Odebrat členy + Odebrat členy + Odebrat členy + + + Odebrat člena a jeho zprávy + Odebrat členy a jejich zprávy + Odebrat členy a jejich zprávy + Odebrat členy a jejich zprávy + Odebrání hesla selhalo Odstraňte své aktuální heslo pro {app_name}. Lokálně uložená data budou znovu zašifrována pomocí náhodně vygenerovaného klíče uloženého ve vašem zařízení. + + Odebírání člena + Odebírání členů + Odebírání členů + Odebírání členů + + Prodloužit + Prodlužování {pro} Odpovědět Požádat o vrácení platby + Požádejte o vrácení platby na webových stránkách {platform} pomocí účtu {platform_account}, kterým jste se zaregistrovali do {pro}. Odeslat znovu + + Znovu odeslat pozvánku + Znovu odeslat pozvánky + Znovu odeslat pozvánky + Znovu odeslat pozvánky + + + Znovu odeslat povýšení + Znovu odeslat povýšení + Znovu odeslat povýšení + Znovu odeslat povýšení + + + Opětovné odesílání pozvánky + Opětovné odesílání pozvánek + Opětovné odesílání pozvánek + Opětovné odesílání pozvánek + + + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Opětovné odesílání povýšení + Načítám informace o zemi... Restartovat Znovu synchronizovat @@ -1183,6 +1423,9 @@ Vrátit zpět Neznámé Nepodporovaný procesor + Aktualizovat + Aktualizovat přístup {pro} + Dva způsoby, jak aktualizovat váš přístup k {pro}: Aktualizace aplikace Upravit informace o komunitě Název a popis komunity jsou viditelné pro všechny členy komunity @@ -1205,6 +1448,7 @@ Naposledy aktualizováno před {relative_time} Aktualizace Aktualizuji... + Navýšení Navýšit {app_name} Navýšit na Nahrávání @@ -1214,6 +1458,9 @@ Opravdu chcete otevřít tuto URL adresu ve vašem prohlížeči?\n\n{url} Odkazy se otevřou ve vašem prohlížeči. Použít rychlý režim + Změňte svůj tarif pomocí {platform_account}, kterým jste se zaregistrovali, prostřednictvím webu {platform}. + Přes webové stránky {platform} + Aktualizujte svůj přístup k {pro} pomocí {platform_account}, se kterým jste se zaregistrovali, prostřednictvím webu {platform_store}. Video Nelze přehrát video. Zobrazit @@ -1222,6 +1469,7 @@ Může to trvat několik minut. Okamžik prosím... Varování + Podpora pro iOS 15 byla ukončena. Aktualizujte na iOS 16 nebo novější verzi, abyste mohli i nadále dostávat aktualizace aplikace. Okno Ano Vy diff --git a/app/src/main/res/values-b+de+DE/strings.xml b/app/src/main/res/values-b+de+DE/strings.xml index aa515a4c1a..6f42c11722 100644 --- a/app/src/main/res/values-b+de+DE/strings.xml +++ b/app/src/main/res/values-b+de+DE/strings.xml @@ -871,12 +871,10 @@ Aktiviert Du hast bereits Lade GIF- und animierte WebP-Bilder als Profilbild hoch! - Hole dir animierte Profilbilder und schalte Premium-Funktionen mit {app_pro} frei Animiertes Profilbild Nutzer können GIFs hochladen GIFs hochladen mit Automatische {pro} Erneuerung in {time} - Du möchtest längere Nachrichten senden? Sende mehr Text und schalte Premium-Funktionen mit {app_pro} frei Mehr Anheftungen gewünscht? Organisiere deine Chats und schalte Premium-Funktionen mit {app_pro} frei Abgelaufen {pro} FAQ diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index dc025bdfdc..2430260df7 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -826,12 +826,9 @@ Activado Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! - Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Imagen de perfil animada los usuarios pueden subir GIFs Sube GIFs con - ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} - ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más diff --git a/app/src/main/res/values-b+es+ES/strings.xml b/app/src/main/res/values-b+es+ES/strings.xml index d25b3df105..f765489d4c 100644 --- a/app/src/main/res/values-b+es+ES/strings.xml +++ b/app/src/main/res/values-b+es+ES/strings.xml @@ -826,12 +826,9 @@ Activado Ya tienes ¡Adelante, sube GIFs e imágenes WebP animadas para tu imagen de perfil! - Consigue imágenes de perfil animadas y desbloquea funciones premium con {app_pro} Imagen de perfil animada los usuarios pueden subir GIFs Sube GIFs con - ¿Quieres enviar mensajes más largos? Envía más texto y desbloquea funciones premium con {app_pro} - ¿Quieres más conversaciones fijadas? Organiza tus chats y desbloquea funciones premium con {app_pro} Sube imágenes de perfil en formato GIF y WebP Chats grupales más grandes de hasta 300 miembros Y muchas funciones exclusivas más diff --git a/app/src/main/res/values-b+fr+FR/strings.xml b/app/src/main/res/values-b+fr+FR/strings.xml index dc1bf3be23..0a7c5a3b0d 100644 --- a/app/src/main/res/values-b+fr+FR/strings.xml +++ b/app/src/main/res/values-b+fr+FR/strings.xml @@ -888,7 +888,6 @@ Tout est prêt ! Vous avez déjà Téléchargez des GIF et des images WebP animées pour votre photo de profil ! - Obtenez des photos de profil animées et débloquez des fonctionnalités premium avec {app_pro} Photo de profil animée les utilisateurs peuvent télécharger des GIFs Photos de profil animées @@ -907,8 +906,6 @@ {price} facturé annuellement {price} facturé mensuellement {price} facturé trimestriellement - Vous voulez envoyer des messages plus longs ? Envoyez plus de messages et débloqué les fonctionnalités premium avec {app_pro} - Vous voulez plus de messages épinglés ? Organisez vos chats et débloquez les fonctionnalités premium avec {app_pro} Expiré Expiration imminente {pro} expire dans {time} diff --git a/app/src/main/res/values-b+hi+IN/strings.xml b/app/src/main/res/values-b+hi+IN/strings.xml index ad3d436827..2be2668f94 100644 --- a/app/src/main/res/values-b+hi+IN/strings.xml +++ b/app/src/main/res/values-b+hi+IN/strings.xml @@ -822,12 +822,9 @@ सक्रिय किया गया आपके पास पहले से ही है आगे बढ़ें और अपनी डिस्प्ले तस्वीर के लिए GIF और एनिमेटेड WebP इमेज अपलोड करें! - एनीमेटेड डिस्प्ले तस्वीरें प्राप्त करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें एनिमेटेड डिस्प्ले तस्वीर उपयोगकर्ता GIF अपलोड कर सकते हैं GIF अपलोड करें - लंबे संदेश भेजना चाहते हैं? अधिक टेक्स्ट भेजें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें - अधिक पिन करना चाहते हैं? अपनी चैट व्यवस्थित करें और {app_pro} के साथ प्रीमियम सुविधाओं का अनलॉक करें GIF और WebP डिस्प्ले तस्वीरें अपलोड करें 300 सदस्यों तक बड़े समूह चैट साथ में कई और विशेष सुविधाएं diff --git a/app/src/main/res/values-b+hu+HU/strings.xml b/app/src/main/res/values-b+hu+HU/strings.xml index 0ec82a806a..42067519d3 100644 --- a/app/src/main/res/values-b+hu+HU/strings.xml +++ b/app/src/main/res/values-b+hu+HU/strings.xml @@ -807,7 +807,6 @@ Kitűzés eltávolítása Beszélgetés kitűzésének eltávolítása Előnézet - Szeretne hosszabb üzeneteket küldeni? Küldjön több szöveget és oldja fel a prémium funkciókat a {app_pro} szolgáltatással Nagyobb csoportos beszélgetések akár 300 taggal Plusz még több exkluzív funkció Legfeljebb 10 000 karakteres üzenetek diff --git a/app/src/main/res/values-b+it+IT/strings.xml b/app/src/main/res/values-b+it+IT/strings.xml index 813021c95a..46e66393a6 100644 --- a/app/src/main/res/values-b+it+IT/strings.xml +++ b/app/src/main/res/values-b+it+IT/strings.xml @@ -822,12 +822,9 @@ Attivato Hai già attivato Carica GIF e immagini WebP animate per la tua immagine del profilo! - Ottieni immagini del profilo animate e sblocca funzionalità premium con {app_pro} Immagine del profilo animata gli utenti possono caricare GIF Carica GIF con - Vuoi inviare messaggi più lunghi? Invia più testo e sblocca funzionalità premium con {app_pro} - Vuoi più chat bloccate? Organizza le tue chat e sblocca le funzionalità premium con {app_pro} Carica immagini profilo in formato GIF e WebP Chat di gruppo maggiori fino a 300 membri E tante altre funzionalità esclusive diff --git a/app/src/main/res/values-b+ja+JP/strings.xml b/app/src/main/res/values-b+ja+JP/strings.xml index 8f4aacc126..a3a0462ac0 100644 --- a/app/src/main/res/values-b+ja+JP/strings.xml +++ b/app/src/main/res/values-b+ja+JP/strings.xml @@ -800,12 +800,9 @@ アクティベート済み すでにご利用中です ディスプレイ画像としてGIFやアニメーションWebP画像をアップロードできます! - アニメーションディスプレイ画像を取得し、{app_pro}でプレミアム機能を解除しましょう アニメーション表示画像 ユーザーはGIFをアップロードできます GIFをアップロード(PRO) - 長文を送りたいですか?{app_pro}でより多くのテキストを送り、プレミアム機能を解除しましょう。 - さらにピン留めしますか?{app_pro}でチャットを整理して、プレミアム機能を解除しましょう GIFとWebPのディスプレイ画像をアップロード 最大300人の大型グループチャット さらに多数の限定機能 diff --git a/app/src/main/res/values-b+nl+NL/strings.xml b/app/src/main/res/values-b+nl+NL/strings.xml index 78174b8c25..de7ddf8235 100644 --- a/app/src/main/res/values-b+nl+NL/strings.xml +++ b/app/src/main/res/values-b+nl+NL/strings.xml @@ -805,6 +805,7 @@ We konden deze ONS niet herkennen. Controleer deze alsjeblieft en probeer het opnieuw. We konden niet zoeken naar deze ONS. Probeer het later opnieuw. Openen + Open de {platform} website Enquête openen Overige Wachtwoord @@ -885,7 +886,6 @@ Alles is geregeld! Je hebt al Upload nu GIF\'s en geanimeerde WebP-afbeeldingen voor je profielfoto! - Krijg geanimeerde profielfoto\'s en ontgrendel premiumfuncties met {app_pro} Geanimeerde profielfoto gebruikers kunnen GIF\'s uploaden Geanimeerde profielfoto\'s @@ -900,8 +900,6 @@ {price} Jaarlijks gefactureerd {price} Maandelijks gefactureerd {price} per kwartaal gefactureerd - Wil je langere berichten versturen? Verstuur meer tekst en ontgrendel premiumfuncties met {app_pro} - Wil je meer vastzetten? Organiseer je chats en ontgrendel premiumfuncties met {app_pro} Verlopen Verloopt binnenkort {pro} FAQ @@ -921,11 +919,16 @@ Je kunt berichten tot 10.000 tekens verzenden in alle gesprekken. Dit bericht maakte gebruik van de volgende {app_pro}-functies: {percent}% korting + Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, moet je hetzelfde {platform_account} gebruiken om een terugbetaling aan te vragen. + Omdat je je oorspronkelijk hebt aangemeld voor {app_pro} via de {platform_store} winkel, wordt je restitutieverzoek afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. 1 maand - {monthly_price} / maand 3 maanden - {monthly_price} / maand 12 maanden - {monthly_price} / maand Het spijt ons dat je vertrekt. Dit moet je weten voordat je een terugbetaling aanvraagt. + {platform} verwerkt nu je terugbetalingsverzoek. Dit duurt meestal 24-48 uur. Afhankelijk van hun beslissing kan je {pro} status wijzigen in {app_name}. Je restitutieverzoek wordt afgehandeld door {app_name} Support.\n\nVraag een restitutie aan door op de knop hieronder te drukken en het restitutieformulier in te vullen.\n\nHoewel {app_name} Support ernaar streeft om restitutieverzoeken binnen 24-72 uur te verwerken, kan het tijdens drukte langer duren. + Je terugbetalingsverzoek wordt uitsluitend afgehandeld door {platform} via de website van {platform}.\n\nVanwege het restitutiebeleid van {platform} hebben ontwikkelaars van {app_name} geen invloed op de uitkomst van terugbetalingsverzoeken. Dit geldt zowel voor de goedkeuring of afwijzing van het verzoek als voor het wel of niet toekennen van een volledige of gedeeltelijke terugbetaling. + Neem contact op met {platform} voor verdere updates over je restitutieverzoek. Vanwege het restitutiebeleid van {platform} hebben de ontwikkelaars van {app_name} geen invloed op de uitkomst van restitutieverzoeken.\n\n{platform} Terugbetalingsondersteuning Terugbetalen {pro} Terugbetaling aangevraagd Verstuur meer met @@ -936,6 +939,7 @@ Onbeperkte Pins Organiseer al je chats met onbeperkt vastgezette gesprekken. Wil je meer uit {app_name} halen? Upgrade naar {app_pro} voor een krachtigere berichtbeleving. + {platform} verwerkt je restitutieverzoek Profiel Toon afbeelding Verwijderen profielfoto mislukt. @@ -1128,6 +1132,7 @@ Weet u zeker dat u deze URL in uw browser wilt openen?\n\n{url} Links worden in uw browser geopend. Gebruik Snelle Modus + Via de {platform} website Video Kan video niet afspelen. Bekijken diff --git a/app/src/main/res/values-b+pl+PL/strings.xml b/app/src/main/res/values-b+pl+PL/strings.xml index 29bbf17c1c..4959d72b38 100644 --- a/app/src/main/res/values-b+pl+PL/strings.xml +++ b/app/src/main/res/values-b+pl+PL/strings.xml @@ -925,7 +925,6 @@ Wszystko gotowe! Masz już Możesz przesyłać GIF-y i animowane obrazy WebP jako swoje zdjęcie profilowe! - Zyskaj animowane zdjęcia profilowe i odblokuj funkcje premium dzięki {app_pro} Animowany obraz profilowy użytkownicy mogą przesyłać GIF-y Animowane obrazy profilu @@ -946,8 +945,6 @@ Opłata roczna: {price} Opłata miesięczna: {price} Opłata kwartalna: {price} - Chcesz wysyłać dłuższe wiadomości? Wyślij więcej tekstu i odblokuj funkcje premium dzięki {app_pro} - Chcesz przypinać więcej czatów? Zorganizuj konwersacje i odblokuj funkcje premium dzięki {app_pro} Niedługo wygaśnie FAQ {pro} Znajdź odpowiedzi na często zadawane pytania w sekcji FAQ {app_pro}. diff --git a/app/src/main/res/values-b+pt+PT/strings.xml b/app/src/main/res/values-b+pt+PT/strings.xml index b5383e4fc8..e774bce0f6 100644 --- a/app/src/main/res/values-b+pt+PT/strings.xml +++ b/app/src/main/res/values-b+pt+PT/strings.xml @@ -822,12 +822,9 @@ Ativado Já tem Agora pode enviar GIFs e imagens WebP animadas para a sua imagem de exibição! - Obtenha imagens de exibição animadas e desbloqueie funcionalidades premium com o {app_pro} Imagem de exibição animada os utilizadores podem carregar GIFs Carregue GIFs com - Quer enviar mensagens mais longas? Envie mais texto e desbloqueie funcionalidades premium com {app_pro} - Quer fixar mais conversas? Organize os seus chats e desbloqueie funcionalidades premium com o {app_pro} Carregue imagens de exibição em GIF e WebP Conversas de grupo maiores com até 300 membros E muitas outras funcionalidades exclusivas diff --git a/app/src/main/res/values-b+ro+RO/strings.xml b/app/src/main/res/values-b+ro+RO/strings.xml index b159d85719..0f0df714cf 100644 --- a/app/src/main/res/values-b+ro+RO/strings.xml +++ b/app/src/main/res/values-b+ro+RO/strings.xml @@ -864,12 +864,9 @@ Activat Deja ai Mergi mai departe și încarcă GIF-uri și imagini WebP animate pentru imaginea ta de profil! - Obține imagini de profil animate și deblochează funcționalități premium cu {app_pro} Poză de profil animată utilizatorii pot încărca GIF-uri Încarcă GIF-uri cu - Vrei să trimiți mesaje mai lungi? Trimite mai mult text și deblochează funcții premium cu {app_pro} - Vrei mai multe fixări? Organizează-ți conversațiile și deblochează funcționalități premium cu {app_pro} Încarcă imagini de profil GIF și WebP Conversații de grup mai mari, cu până la 300 de membri Și multe alte funcționalități exclusive diff --git a/app/src/main/res/values-b+ru+RU/strings.xml b/app/src/main/res/values-b+ru+RU/strings.xml index 6453b14b09..3ffba0933d 100644 --- a/app/src/main/res/values-b+ru+RU/strings.xml +++ b/app/src/main/res/values-b+ru+RU/strings.xml @@ -916,12 +916,9 @@ Активирован У вас уже есть Вперёд! И загружай анимированные GIF и WebP для вашего изображения! - Получите анимированное изображение профиля и другие разблокированные премиум функции с {app_pro} Анимированное изображение профиля пользователи могут загружать GIF-файлы Загружайте GIF с - Хотите отправлять более длинные сообщения? Отправляйте больше текста и используйте премиум функции с {app_pro} - Хотите больше закреплений? Организуйте свои чаты и получайте доступ к премиум функциям с {app_pro} Загрузка изображений в формате GIF и WebP Групповые чаты до 300 участников + множество эксклюзивных функций diff --git a/app/src/main/res/values-b+sv+SE/strings.xml b/app/src/main/res/values-b+sv+SE/strings.xml index c331962463..7bf887c8f3 100644 --- a/app/src/main/res/values-b+sv+SE/strings.xml +++ b/app/src/main/res/values-b+sv+SE/strings.xml @@ -876,12 +876,9 @@ Aktiverat Du har redan Fortsätt och ladda upp GIF:ar och animerade WebP-bilder som visningsbild! - Skaffa animerade visningsbilder och lås upp premiumfunktioner med {app_pro} Animerad visningsbild användare kan ladda upp GIF:ar Ladda upp GIF:ar med - Vill du skicka längre meddelanden? Skicka mer text och lås upp premiumfunktioner med {app_pro} - Vill du ha fler fästen? Organisera dina chattar och lås upp premiumfunktioner med {app_pro} Ladda upp GIF- och WebP-visningsbilder Större gruppchattar upp till 300 medlemmar Plus många fler exklusiva funktioner diff --git a/app/src/main/res/values-b+tr+TR/strings.xml b/app/src/main/res/values-b+tr+TR/strings.xml index 31e6060a3b..5d91a07167 100644 --- a/app/src/main/res/values-b+tr+TR/strings.xml +++ b/app/src/main/res/values-b+tr+TR/strings.xml @@ -818,12 +818,9 @@ Etkinleştirildi Zaten sahipsiniz Hadi, profil resminiz için GIF\'ler ve animasyonlu WebP görselleri yükleyin! - Animasyonlu profil resimleri edinin ve {app_pro} ile premium özelliklerin kilidini açın Profil Onur Seçin kullanıcılar GIF yükleyebilir ile GIF Yükleyin - Daha uzun mesajlar mı göndermek istiyorsunuz? {app_pro} ile daha fazla metin gönderin ve premium özelliklerin kilidini açın - Daha fazla sabitleme mi istiyorsunuz? Sohbetlerinizi düzenleyin ve {app_pro} ile premium özelliklerin kilidini açın GIF ve WebP profil resmi yükleme 300 üyeye kadar daha büyük grup sohbetleri Ayrıca daha birçok özel özellik diff --git a/app/src/main/res/values-b+uk+UA/strings.xml b/app/src/main/res/values-b+uk+UA/strings.xml index 3e1d6cc560..366002d01d 100644 --- a/app/src/main/res/values-b+uk+UA/strings.xml +++ b/app/src/main/res/values-b+uk+UA/strings.xml @@ -932,7 +932,6 @@ Готово! У вас вже є Не зволікайте і завантажуйте GIF та анімовані WebP картинки для свого аватара! - Отримайте анімовані аватари та розблокуйте преміальні функції з {app_pro} Анімоване зображення профілю користувачі можуть завантажувати GIF Анімовані зображення облікового запису @@ -947,8 +946,6 @@ {price} сплата щорічно {price} сплата щомісячно {price} сплата щоквартально - Хочете відправляти довші повідомлення? Надсилайте більше тексту та розблокуйте преміальні функції застосунку з Session Pro - Потрібно більше закріплених бесід? Впорядкуйте свої чати та розблокуйте преміальні функції з {app_pro} Підписка сплила Невдовзі спливе підписка {pro} спливає за {time} diff --git a/app/src/main/res/values-b+zh+CN/strings.xml b/app/src/main/res/values-b+zh+CN/strings.xml index 228b348d01..edde80606a 100644 --- a/app/src/main/res/values-b+zh+CN/strings.xml +++ b/app/src/main/res/values-b+zh+CN/strings.xml @@ -65,6 +65,7 @@ 笔记 股票 天气 + 自动深色模式 隐藏菜单栏 语言 选择{app_name}的语言设置。更改语言设置后{app_name}将重新启动。 @@ -159,6 +160,7 @@ 您确定要取消屏蔽{name}和其他{count}人吗? 您确定要取消屏蔽{name}和其他1人吗? 取消屏蔽{name} + 查看和管理已屏蔽的联系人。 语音通话 {name}呼叫过您 您无法开始新的通话。请先结束当前的通话。 @@ -193,6 +195,7 @@ {app_name}需要相机权限来拍摄照片和视频,或扫描二维码。 {app_name}需要相机访问权限才能扫描二维码 取消 + 更改 更改密码失败 清除 清除所有 @@ -281,6 +284,7 @@ 新建会话 您还没有任何会话 按回车键发送消息而非换行 + 使用 Shift + Enter 键发送 所有媒体 拼写检查 在输入消息时启用拼写检查。 @@ -289,7 +293,9 @@ 复制 创建 正在创建通话 + 当前密码 剪切 + 深色模式 您确定要删除此设备上的所有消息、附件和帐户数据,并创建新帐户吗? 发生数据库错误。\n\n请导出您的应用日志以进行故障排除。如果不成功,请重新安装{app_name}并恢复您的帐户。 您确定要删除此设备上的所有消息、附件和帐户数据,并从网络中恢复你的帐户吗? @@ -381,6 +387,7 @@ {admin_name}更改了阅后即焚消息设置。 更改了阅后即焚消息设置。 放弃 + 显示 它可以是您的真名、化名或任何您喜欢的内容——并且您可以随时进行更改。 输入您想显示的名称 请输入显示名称 @@ -424,6 +431,7 @@ 需要改进 {emoji} 很棒 {emoji} 您已使用 {app_name} 一段时间了,感觉如何?非常希望能听到您的反馈。 + 进入 请检查您的网络连接并重试。 复制错误并退出 数据库错误 @@ -431,8 +439,11 @@ 发生了未知错误。 下载失败 失败 + 反馈 + 通过完成一份简短的调查问卷分享您对 {app_name} 的使用体验。 文件 文件 + 跟随系统设置。 永久 发送自: 全屏 @@ -533,8 +544,10 @@ 导出您的日志,然后通过{app_name}的帮助服务台上传日志。 保存到桌面 支持 + 帮助将 {app_name} 本地化翻译成超过 80 种语言! 感谢您的反馈 隐藏 + 切换系统菜单栏可见性。 你确定要从对话列表中隐藏Note to Self吗? 隐藏其它 图片 @@ -572,6 +585,7 @@ 您无法在绝对的元数据安全保障前提下发送链接预览。 链接预览已关闭 {app_name}必须访问链接的网站以生成您发送和接收的链接预览。\n\n您可以在{app_name}的设置中启用该功能。 + 链接 加载账户 正在加载您的账户 正在加载... @@ -584,6 +598,7 @@ 锁定状态 点击解锁 {app_name}已解锁 + 日志 管理成员 最大 媒体 @@ -606,6 +621,7 @@ 邀请 消息 了解更多 + 复制消息 此消息内容为空。 消息发送失败 消息字数已满 @@ -664,6 +680,7 @@ 消息太长 请将消息缩短至 {limit} 个字符或更少。 消息太长 + 新密码 下一步 {name}选择一个昵称。该昵称将在您的一对一和群组对话中显示。 输入昵称 @@ -678,6 +695,7 @@ 您在备忘录中没有消息。 隐藏备忘录 您确定要隐藏备忘录吗? + 通知显示 所有信息 通知内容 在通知中显示的信息。 @@ -742,8 +760,11 @@ 打开 打开调查问卷 其它 + 密码 更改密码 + 更改 {app_name} 的解锁密码。 确认密码 + 创建密码 您当前的密码不正确。 请输入密码 请输入您当前的密码 @@ -753,8 +774,14 @@ 密码不一致 设置密码失败 密码不正确 + 确认新密码 移除密码 + 您的密码已被移除。 设置密码 + 长于12个字符 + 密码强度指标 + 设置强密码有助于在设备丢失或被盗时保护您的消息和附件。 + 密码 粘贴 授权变更 {app_name}需要音乐和音频权限才能发送文件、音乐和音频,但该权限已被永久拒绝。请进入应用程序设置→权限,打开“音乐和音频”权限。 @@ -767,6 +794,7 @@ 请允许访问摄像头以进行视频通话。 {app_name}的屏幕锁功能使用 Face ID。 保留在系统托盘 + 当您关闭窗口,{app_name}将在后台继续运行。 {app_name}需要照片库访问权限以继续。您可以在iOS设置中启用访问。 需要本地网络访问权限才能进行通话。在设置中允许“本地网络”权限以继续。 {app_name}需要访问本地网络才能进行语音和视频通话。 @@ -793,16 +821,15 @@ 置顶会话 取消置顶 取消置顶会话 + 偏好设置 通知效果预览 + 预览通知 已激活 您已拥有 快去为头像上传 GIF 或动画 WebP 图片吧! - 获取动画头像并使用 {app_pro} 解锁高级功能 动画头像 用户可上传 GIF 使用 PRO 上传 GIF - 想发送更长的消息?使用 {app_pro} 发送更多文本并解锁高级功能 - 想要固定更多对话?使用 {app_pro} 整理你的聊天并解锁高级功能 上传 GIF 和 WebP 头像 更大的群组聊天,最多可容纳 300 名成员 还有更多专属功能等你解锁 @@ -812,9 +839,9 @@ 该群组已扩容!因管理员升级为 PRO,现支持最多 300 名成员 附件大小已增加 消息长度已增加 + 您作为管理员的群组自动升级成支持300名成员。 此消息使用了以下 {app_pro} 功能: 发送更多内容,体验 - 想充分体验 {app_name}?升级到 {app_pro} 享受更强大的消息体验。 个人资料 头像 移除头像失败。 @@ -943,12 +970,15 @@ 显示备忘录 你确定要在对话列表中显示 Note to Self吗? 贴图 + 强度 + 遇到问题?浏览帮助文章或向 {app_name} 支持提交工单。 跳转到支持页面 系统信息:{information} 点击以重试 继续 默认 错误 + 主题预览 基于您与 {name} 之前的互动,其 Account ID 对您可见 盲化 ID 在社区中用于减少垃圾信息并提高隐私性 重试 @@ -971,6 +1001,7 @@ 请输入更简短的群组描述 有新版本的{app_name}可用,点击更新 {app_name}有新版本({version})可用。 + 更新个人资料信息 跳转到版本信息 {app_name}更新 版本 {version} @@ -993,4 +1024,6 @@ 窗口 + 缩放系数 + 调整文本和视觉元素的大小。 \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+TW/strings.xml b/app/src/main/res/values-b+zh+TW/strings.xml index 476980dfb9..2a9ba486a3 100644 --- a/app/src/main/res/values-b+zh+TW/strings.xml +++ b/app/src/main/res/values-b+zh+TW/strings.xml @@ -800,12 +800,9 @@ 已啟用 您已擁有 您可以為您的顯示圖片上傳 GIF 或動畫 WebP 圖片了! - 取得動畫顯示圖片並透過 {app_pro} 解鎖進階功能 動畫顯示圖片 用戶可以上傳 GIF 使用 {app_pro} 上傳 GIF 圖片 - 想傳送更長的訊息嗎?與 {app_pro} 一起傳送更多文字並解鎖進階功能 - 想要釘選更多對話嗎?使用 {app_pro} 整理您的聊天並解鎖進階功能 上傳 GIF 和 WebP 顯示圖片 最大支援 300 位成員的大型群組聊天室 以及更多獨家功能 @@ -817,7 +814,6 @@ 訊息長度提升 本訊息使用了以下 {app_pro} 功能: 升級後可傳送更多內容 - 想要充分利用 {app_name}?升級為 {app_pro},享受更強大的訊息體驗。 個人檔案 顯示圖片 無法刪除顯示圖片。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01d1ab5eab..135bcf4846 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1001,7 +1001,7 @@ accepted an invite to join the group. Your {app_pro} access was updated! You will be billed when {pro} is automatically renewed on {date}. You’ve already got Go ahead and upload GIFs and animated WebP images for your display picture! - Get animated display pictures and unlock premium features with {app_pro} + Get animated display pictures and unlock premium features with {app_pro} Beta Animated Display Picture users can upload GIFs Animated Display Pictures @@ -1020,9 +1020,9 @@ accepted an invite to join the group. {price} Billed Annually {price} Billed Monthly {price} Billed Quarterly - Want to send longer messages? Send more text and unlock premium features with {app_pro} + Want to send longer messages?\n Send more text and unlock premium features with {app_pro} Beta Want more pins? Organize your chats and unlock premium features with {app_pro} - Want more than {limit} pins? Organize your chats and unlock premium features with {app_pro} + Want more than {limit} pins?\nOrganize your chats and unlock premium features with {app_pro} Beta Sorry to see you cancel {pro}. Here\'s what you need to know before canceling your {pro} access. Cancellation Canceling {pro} access will prevent automatic renewal from occurring before {pro} access expires. Canceling {pro} does not result in a refund. You will continue to be able to use {app_pro} features until your {pro} access expires.\n\nBecause you originally signed up for {app_pro} using your {platform_account}, you\'ll need to use the same {platform_account} to cancel {pro}. @@ -1094,7 +1094,7 @@ accepted an invite to join the group. Renew your {pro} access from the {app_pro} settings on a linked device with {app_name} installed via the {platform_store} or {platform_store_other}. Want to send longer messages again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to use {app_name} to its max potential again?\nRenew your {pro} access to unlock the features you’ve been missing out on. - Want to pin more than 5 conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. + Want to pin more than {limit} conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. Want to pin more conversations again?\nRenew your {pro} access to unlock the features you’ve been missing out on. By renewing, you agree to the {app_pro} Terms of Service {icon} and Privacy Policy {icon} Pro renewal unsuccessful, retrying soon From 4187742955b3f3b3537c68079ffa2b8bf244d39a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 10 Nov 2025 13:13:57 +1100 Subject: [PATCH 125/219] New strings --- .../securesms/pro/ProStatusManager.kt | 23 +++++++++++++++++++ .../securesms/ui/ProComponents.kt | 1 + 2 files changed, 24 insertions(+) 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 a0c7563ada..f291bb6543 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -276,6 +276,29 @@ class ProStatusManager @Inject constructor( // we should `AddProPaymentRequest` with exponential backoff // the call might fail until the back end has had time to acknowledge the payment + + /** + * Here are the errors from the back end that we will need to be aware of + * UnknownPayment: means it's potentially not acknowledged yet so might need to keep trying until this work or times out + * Error: is non retryable - we might want a custom UI for this. + * + * + * /// Payment was claimed and the pro proof was successfully generated + * Success = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_SUCCESS, + * + * /// Backend encountered an error when attempting to claim the payment + * Error = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ERROR, + * + * /// Request JSON failed to be parsed correctly, payload was malformed or missing values + * ParseError = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_PARSE_ERROR, + * + * /// Payment is already claimed + * AlreadyRedeemed = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_ALREADY_REDEEMED, + * + * /// Payment transaction attempted to claim a payment that the backend does not have. Either the + * /// payment doesn't exist or the backend has not witnessed the payment from the provider yet. + * UnknownPayment = SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS_UNKNOWN_PAYMENT, + */ } enum class MessageProFeature { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 86e5989152..5642474178 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -680,6 +680,7 @@ fun PinProCTA( .toString() !overTheLimit && expired -> Phrase.from(context, R.string.proRenewPinFiveConversations) + .put(LIMIT_KEY, ProStatusManager.MAX_PIN_REGULAR.toString()) .put(PRO_KEY, NonTranslatableStringConstants.PRO) .format() .toString() From 09050b4b4bd96cabaffbe3ac9b0fcb8041d2308f Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:04:50 +1100 Subject: [PATCH 126/219] Merge pull request #1668 from session-foundation/integrate-session-pro Integrate session protocol APIs from libsession-util --- .../messaging/jobs/AttachmentDownloadJob.kt | 20 +- .../messaging/jobs/AttachmentUploadJob.kt | 25 +- .../messaging/jobs/BatchMessageReceiveJob.kt | 8 +- .../messaging/jobs/InviteContactsJob.kt | 40 +- .../messaging/jobs/MessageSendJob.kt | 23 +- .../jobs/SessionJobManagerFactories.kt | 10 +- .../messaging/messages/Destination.kt | 16 +- .../sending_receiving/GroupMessageHandler.kt | 219 +++++++ .../sending_receiving/MessageDecrypter.kt | 1 + .../sending_receiving/MessageEncrypter.kt | 1 + .../sending_receiving/MessageParser.kt | 240 ++++++++ .../sending_receiving/MessageReceiver.kt | 20 +- .../MessageRequestResponseHandler.kt | 29 +- .../sending_receiving/MessageSender.kt | 244 +++----- .../ReceivedMessageHandler.kt | 5 +- .../ReceivedMessageProcessor.kt | 563 ++++++++++++++++++ .../VisibleMessageHandler.kt | 258 ++++++++ .../pollers/OpenGroupPoller.kt | 224 +++---- .../sending_receiving/pollers/Poller.kt | 172 ++++-- .../messaging/utilities/MessageWrapper.kt | 75 +-- .../org/session/libsession/snode/SnodeAPI.kt | 232 +------- .../libsession/snode/model/BatchResponse.kt | 18 +- .../snode/model/MessageResponses.kt | 60 +- .../utilities/ConfigFactoryProtocol.kt | 3 +- .../crypto/PushTransportDetails.java | 5 + .../database/LokiAPIDatabaseProtocol.kt | 5 - .../securesms/MediaPreviewActivity.kt | 7 +- .../components/TypingStatusSender.java | 6 +- .../securesms/configs/ConfigToDatabaseSync.kt | 4 +- .../securesms/configs/ConfigUploader.kt | 6 +- .../DisappearingMessages.kt | 3 +- .../conversation/v2/ConversationActivityV2.kt | 16 +- .../v2/utilities/ResendMessageUtilities.kt | 18 +- .../securesms/database/LokiAPIDatabase.kt | 40 +- .../database/ReceivedMessageHashDatabase.kt | 146 +++++ .../securesms/database/ThreadDatabase.java | 6 +- .../database/helpers/SQLCipherOpenHelper.java | 10 +- .../securesms/dependencies/ConfigFactory.kt | 12 +- .../securesms/groups/GroupLeavingWorker.kt | 5 +- .../securesms/groups/GroupManagerV2Impl.kt | 30 +- .../securesms/groups/GroupPoller.kt | 104 ++-- .../handler/RemoveGroupMemberHandler.kt | 3 +- .../securesms/media/MediaOverviewViewModel.kt | 5 +- .../AndroidAutoHeardReceiver.java | 17 +- .../AndroidAutoReplyReceiver.java | 10 +- .../notifications/MarkReadProcessor.kt | 132 ++++ .../notifications/MarkReadReceiver.kt | 113 +--- .../securesms/notifications/PushReceiver.kt | 158 ++--- .../notifications/RemoteReplyReceiver.java | 10 +- .../manager/CreateAccountManager.kt | 6 +- .../onboarding/manager/LoadAccountManager.kt | 6 +- .../repository/ConversationRepository.kt | 13 +- .../securesms/webrtc/CallManager.kt | 21 +- gradle/libs.versions.toml | 2 +- 54 files changed, 2340 insertions(+), 1085 deletions(-) create mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt create mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt create mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt create mode 100644 app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index a6747b3b10..46f473e210 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,10 +1,8 @@ package org.session.libsession.messaging.jobs -import android.content.Context import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.database.MessageDataProvider @@ -16,7 +14,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -252,21 +249,18 @@ class AttachmentDownloadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + @Assisted("attachmentID") attachmentID: Long, + mmsMessageId: Long + ): AttachmentDownloadJob override fun create(data: Data): AttachmentDownloadJob { - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), mmsMessageId = data.getLong(TS_INCOMING_MESSAGE_ID_KEY) ) } } - - @AssistedFactory - interface Factory { - fun create( - @Assisted("attachmentID") attachmentID: Long, - mmsMessageId: Long - ): AttachmentDownloadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index bd31a3c3f1..86ce01eec7 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -15,7 +15,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -38,6 +37,7 @@ class AttachmentUploadJob @AssistedInject constructor( private val attachmentProcessor: AttachmentProcessor, private val preferences: TextSecurePreferences, private val fileServerApi: FileServerApi, + private val messageSender: MessageSender, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -219,7 +219,7 @@ class AttachmentUploadJob @AssistedInject constructor( private fun failAssociatedMessageSendJob(e: Exception) { val messageSendJob = storage.getMessageSendJob(messageSendJobID) - MessageSender.handleFailedMessageSend(this.message, e) + messageSender.handleFailedMessageSend(this.message, e) if (messageSendJob != null) { storage.markJobAsFailedPermanently(messageSendJobID) } @@ -244,7 +244,14 @@ class AttachmentUploadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory): Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + attachmentID: Long, + @Assisted("threadID") threadID: String, + message: Message, + messageSendJobID: String + ): AttachmentUploadJob override fun create(data: Data): AttachmentUploadJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -259,7 +266,7 @@ class AttachmentUploadJob @AssistedInject constructor( return null } input.close() - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), threadID = data.getString(THREAD_ID_KEY)!!, message = message, @@ -267,14 +274,4 @@ class AttachmentUploadJob @AssistedInject constructor( ) } } - - @AssistedFactory - interface Factory { - fun create( - attachmentID: Long, - @Assisted("threadID") threadID: String, - message: Message, - messageSendJobID: String - ): AttachmentUploadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index fc1bbfac42..cdf760a7b7 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -51,6 +51,7 @@ data class MessageReceiveParameters( val closedGroup: Destination.ClosedGroup? = null ) +@Deprecated("BatchMessageReceiveJob is now only here so that existing persisted jobs can be processed.") class BatchMessageReceiveJob @AssistedInject constructor( @Assisted private val messages: List, @Assisted val fromCommunity: Address.Community?, // The community the messages are received in, if any @@ -62,6 +63,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( private val messageNotifier: MessageNotifier, private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, + private val messageReceiver: MessageReceiver, ) : Job { override var delegate: JobDelegate? = null @@ -105,6 +107,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( fromCommunity = fromCommunity, threadDatabase = threadDatabase, recipientRepository = recipientRepository, + messageReceiver = messageReceiver, ) } @@ -157,7 +160,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse( + val (message, proto) = messageReceiver.parse( data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, @@ -358,7 +361,8 @@ class BatchMessageReceiveJob @AssistedInject constructor( @AssistedFactory abstract class Factory : Job.DeserializeFactory { - abstract fun create( + @Deprecated("New code should try to handle message directly instead of creating this job") + protected abstract fun create( messages: List, fromCommunity: Address.Community?, ): BatchMessageReceiveJob diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 1a4c5cc9f9..70157d8ef1 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -2,6 +2,9 @@ package org.session.libsession.messaging.jobs import android.widget.Toast import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -16,13 +19,19 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.getGroup import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { +class InviteContactsJob @AssistedInject constructor( + @Assisted val groupSessionId: String, + @Assisted val memberSessionIds: Array, + private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, +) : Job { companion object { const val KEY = "InviteContactJob" @@ -37,8 +46,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override val maxFailureCount: Int = 1 override suspend fun execute(dispatcherName: String) { - val configs = MessagingModuleConfiguration.shared.configFactory - val group = requireNotNull(configs.getGroup(AccountId(groupSessionId))) { + val group = requireNotNull(configFactory.getGroup(AccountId(groupSessionId))) { "Group must exist to invite" } @@ -54,7 +62,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< runCatching { // Make the request for this member val memberId = AccountId(memberSessionId) - val (groupName, subAccount) = configs.withMutableGroupConfigs(sessionId) { configs -> + val (groupName, subAccount) = configFactory.withMutableGroupConfigs(sessionId) { configs -> configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } @@ -76,14 +84,14 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< sentTimestamp = timestamp } - MessageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) + messageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) } } } val results = memberSessionIds.zip(requests.awaitAll()) - configs.withMutableGroupConfigs(sessionId) { configs -> + configFactory.withMutableGroupConfigs(sessionId) { configs -> results.forEach { (memberSessionId, result) -> configs.groupMembers.get(memberSessionId)?.let { member -> if (result.isFailure) { @@ -96,8 +104,8 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< } } - val groupName = configs.withGroupConfigs(sessionId) { it.groupInfo.getName() } - ?: configs.getGroup(sessionId)?.name + val groupName = configFactory.withGroupConfigs(sessionId) { it.groupInfo.getName() } + ?: configFactory.getGroup(sessionId)?.name // Gather all the exceptions, while keeping track of the invitee account IDs val failures = results.mapNotNull { (id, result) -> @@ -140,4 +148,20 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override fun getFactoryKey(): String = KEY + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + groupSessionId: String, + memberSessionIds: Array, + ): InviteContactsJob + + override fun create(data: Data): InviteContactsJob? { + val groupSessionId = data.getString(GROUP) ?: return null + val memberSessionIds = data.getStringArray(MEMBER) ?: return null + return create( + groupSessionId = groupSessionId, + memberSessionIds = memberSessionIds, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 238b7edb04..1cb9e88bfa 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -34,6 +34,7 @@ class MessageSendJob @AssistedInject constructor( private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, ) : Job { object AwaitingAttachmentUploadException : Exception("Awaiting attachment upload.") @@ -97,7 +98,7 @@ class MessageSendJob @AssistedInject constructor( } } - MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) + messageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) @@ -173,7 +174,14 @@ class MessageSendJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + message: Message, + destination: Destination, + statusCallback: SendChannel>? = null + ): MessageSendJob override fun create(data: Data): MessageSendJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -201,20 +209,11 @@ class MessageSendJob @AssistedInject constructor( } destinationInput.close() // Return - return factory.create( + return create( message = message, destination = destination, statusCallback = null ) } } - - @AssistedFactory - interface Factory { - fun create( - message: Message, - destination: Destination, - statusCallback: SendChannel>? = null - ): MessageSendJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cd0468d9f2..e1780ce19f 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -8,18 +8,20 @@ class SessionJobManagerFactories @Inject constructor( private val batchFactory: BatchMessageReceiveJob.Factory, private val trimThreadFactory: TrimThreadJob.Factory, private val messageSendJobFactory: MessageSendJob.Factory, - private val deleteJobFactory: OpenGroupDeleteJob.Factory + private val deleteJobFactory: OpenGroupDeleteJob.Factory, + private val inviteContactsJobFactory: InviteContactsJob.Factory, ) { fun getSessionJobFactories(): Map> { return mapOf( - AttachmentDownloadJob.KEY to AttachmentDownloadJob.DeserializeFactory(attachmentDownloadJobFactory), - AttachmentUploadJob.KEY to AttachmentUploadJob.DeserializeFactory(attachmentUploadJobFactory), - MessageSendJob.KEY to MessageSendJob.DeserializeFactory(messageSendJobFactory), + AttachmentDownloadJob.KEY to attachmentDownloadJobFactory, + AttachmentUploadJob.KEY to attachmentUploadJobFactory, + MessageSendJob.KEY to messageSendJobFactory, NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), TrimThreadJob.KEY to trimThreadFactory, BatchMessageReceiveJob.KEY to batchFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, + InviteContactsJob.KEY to inviteContactsJobFactory, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt index bb8c29a0ce..5ddbad9fa1 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -8,12 +8,6 @@ sealed class Destination { data class Contact(var publicKey: String) : Destination() { internal constructor(): this("") } - data class LegacyClosedGroup(var groupPublicKey: String) : Destination() { - internal constructor(): this("") - } - data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { - internal constructor(): this("", "") - } data class ClosedGroup(var publicKey: String): Destination() { internal constructor(): this("") } @@ -39,9 +33,6 @@ sealed class Destination { is Address.Standard -> { Contact(address.address) } - is Address.LegacyGroup -> { - LegacyClosedGroup(address.groupPublicKeyHex) - } is Address.Community -> { OpenGroup(roomToken = address.room, server = address.serverUrl, fileIds = fileIds) } @@ -63,9 +54,10 @@ sealed class Destination { is Address.Group -> { ClosedGroup(address.accountId.hexString) } - else -> { - throw Exception("TODO: Handle legacy closed groups.") - } + + is Address.Blinded, + is Address.LegacyGroup, + is Address.Unknown -> error("Unsupported address as destination: $address") } } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt new file mode 100644 index 0000000000..abeb569713 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -0,0 +1,219 @@ +package org.session.libsession.messaging.sending_receiving + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import java.security.SignatureException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GroupMessageHandler @Inject constructor( + private val profileUpdateHandler: ProfileUpdateHandler, + private val storage: StorageProtocol, + private val groupManagerV2: GroupManagerV2, + @param:ManagerScope private val scope: CoroutineScope, +) { + fun handleGroupUpdated(message: GroupUpdated, groupId: AccountId?) { + val inner = message.inner + if (groupId == null && + !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { + throw NullPointerException("Message wasn't polled from a closed group!") + } + + // Update profile if needed + ProfileUpdateHandler.Updates.create( + name = message.profile?.displayName, + picUrl = message.profile?.profilePictureURL, + picKey = message.profile?.profileKey, + blocksCommunityMessageRequests = null, + proStatus = null, + profileUpdateTime = null + )?.let { updates -> + profileUpdateHandler.handleProfileUpdate( + senderId = AccountId(message.sender!!), + updates = updates, + fromCommunity = null // Groupv2 is not a community + ) + } + + when { + inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message) + inner.hasInviteResponse() -> handleInviteResponse(message, groupId!!) + inner.hasPromoteMessage() -> handlePromotionMessage(message) + inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, groupId!!) + inner.hasMemberChangeMessage() -> handleMemberChange(message, groupId!!) + inner.hasMemberLeftMessage() -> handleMemberLeft(message, groupId!!) + inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, groupId!!) + inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, groupId!!) + } + } + + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated) { + val storage = storage + val ourUserId = storage.getUserPublicKey()!! + val invite = message.inner.inviteMessage + val groupId = AccountId(invite.groupSessionId) + verifyAdminSignature( + groupSessionId = groupId, + signatureData = invite.adminSignature.toByteArray(), + messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!) + ) + + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handleInvitation( + groupId = groupId, + groupName = invite.name, + authData = invite.memberAuthData.toByteArray(), + inviter = adminId, + inviterName = message.profile?.displayName, + inviteMessageHash = message.serverHash!!, + inviteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite message", e) + } + } + } + + /** + * Does nothing on successful signature verification, throws otherwise. + * Assumes the signer is using the ed25519 group key signing key + * @param groupSessionId the AccountId of the group to check the signature against + * @param signatureData the byte array supplied to us through a protobuf message from the admin + * @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}` + * @throws SignatureException if signature cannot be verified with given parameters + */ + private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { + val groupPubKey = groupSessionId.pubKeyBytes + if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { + throw SignatureException("Verification failed for signature data") + } + } + + private fun handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { + val sender = message.sender!! + // val profile = message // maybe we do need data to be the inner so we can access profile + val approved = message.inner.inviteResponse.isApproved + scope.launch { + try { + groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite response", e) + } + } + } + + + private fun handlePromotionMessage(message: GroupUpdated) { + val promotion = message.inner.promoteMessage + val seed = promotion.groupIdentitySeed.toByteArray() + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handlePromotion( + groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), + groupName = promotion.name, + adminKeySeed = seed, + promoter = adminId, + promoterName = message.profile?.displayName, + promoteMessageHash = message.serverHash!!, + promoteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle promotion message", e) + } + } + } + + private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { + val inner = message.inner + val infoChanged = inner.infoChangeMessage ?: return + if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature") + val adminSignature = infoChanged.adminSignature + val type = infoChanged.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeSignature(type, timestamp)) + + groupManagerV2.handleGroupInfoChange(message, closedGroup) + } + + + private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { + val memberChange = message.inner.memberChangeMessage + val type = memberChange.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, + memberChange.adminSignature.toByteArray(), + buildMemberChangeSignature(type, timestamp) + ) + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { + scope.launch { + try { + groupManagerV2.handleMemberLeftMessage( + AccountId(message.sender!!), closedGroup + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle member left message", e) + } + } + } + + private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) { + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) { + val deleteMemberContent = message.inner.deleteMemberContent + val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf() + + val hasValidAdminSignature = adminSig.isNotEmpty() && runCatching { + verifyAdminSignature( + closedGroup, + adminSig, + buildDeleteMemberContentSignature( + memberIds = deleteMemberContent.memberSessionIdsList.asSequence().map(::AccountId).asIterable(), + messageHashes = deleteMemberContent.messageHashesList, + timestamp = message.sentTimestamp!!, + ) + ) + }.isSuccess + + scope.launch { + try { + groupManagerV2.handleDeleteMemberContent( + groupId = closedGroup, + deleteMemberContent = deleteMemberContent, + timestamp = message.sentTimestamp!!, + sender = AccountId(message.sender!!), + senderIsVerifiedAdmin = hasValidAdminSignature + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle delete member content", e) + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 4ee0cd8bc1..6418ac99bd 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -10,6 +10,7 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to decrypt/decode message using SessionProtocol API") object MessageDecrypter { /** diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 17f16ddbfe..cc09b12a37 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -8,6 +8,7 @@ import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to encrypt/encode message using SessionProtocol API") object MessageEncrypter { /** diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt new file mode 100644 index 0000000000..26daf05fc4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -0,0 +1,240 @@ +package org.session.libsession.messaging.sending_receiving + +import network.loki.messenger.libsession_util.protocol.DecodedEnvelope +import network.loki.messenger.libsession_util.protocol.SessionProtocol +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +@Singleton +class MessageParser @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, +) { + + //TODO: Obtain proBackendKey from somewhere + private val proBackendKey = ByteArray(32) + + // A faster way to check if the user is blocked than to go through RecipientRepository + private fun isUserBlocked(accountId: AccountId): Boolean { + return configFactory.withUserConfigs { it.contacts.get(accountId.hexString) } + ?.blocked == true + } + + + private fun createMessageFromProto(proto: SignalServiceProtos.Content, isGroupMessage: Boolean): Message { + val message = ReadReceipt.fromProto(proto) ?: + TypingIndicator.fromProto(proto) ?: + DataExtractionNotification.fromProto(proto) ?: + ExpirationTimerUpdate.fromProto(proto, isGroupMessage) ?: + UnsendRequest.fromProto(proto) ?: + MessageRequestResponse.fromProto(proto) ?: + CallMessage.fromProto(proto) ?: + GroupUpdated.fromProto(proto) ?: + VisibleMessage.fromProto(proto) + + if (message == null) { + throw NonRetryableException("Unknown message type") + } + + return message + } + + private fun parseMessage( + decodedEnvelope: DecodedEnvelope, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + senderIdPrefix: IdPrefix + ): Pair { + return parseMessage( + sender = AccountId(senderIdPrefix, decodedEnvelope.senderX25519PubKey.data), + contentPlaintext = decodedEnvelope.contentPlainText.data, + messageTimestampMs = decodedEnvelope.timestamp.toEpochMilli(), + relaxSignatureCheck = relaxSignatureCheck, + checkForBlockStatus = checkForBlockStatus, + isForGroup = isForGroup, + currentUserId = currentUserId, + currentUserBlindedIDs = currentUserBlindedIDs, + ) + } + + private fun parseMessage( + sender: AccountId, + contentPlaintext: ByteArray, + messageTimestampMs: Long, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair { + val proto = SignalServiceProtos.Content.parseFrom(contentPlaintext) + + // Check signature + if (proto.hasSigTimestampMs()) { + val diff = abs(proto.sigTimestampMs - messageTimestampMs) + if ( + (!relaxSignatureCheck && diff != 0L ) || + (relaxSignatureCheck && diff > TimeUnit.HOURS.toMillis(6))) { + throw NonRetryableException("Invalid signature timestamp") + } + } + + val message = createMessageFromProto(proto, isGroupMessage = isForGroup) + + // Blocked sender check + if (checkForBlockStatus && isUserBlocked(sender) && message.shouldDiscardIfBlocked()) { + throw NonRetryableException("Sender($sender) is blocked from sending message to us") + } + + // Valid self-send messages + val isSenderSelf = sender == currentUserId || sender in currentUserBlindedIDs + if (isSenderSelf && !message.isSelfSendValid) { + throw NonRetryableException("Ignoring self send message") + } + + // Fill in message fields + message.sender = sender.hexString + message.recipient = currentUserId.hexString + message.sentTimestamp = messageTimestampMs + message.receivedTimestamp = snodeClock.currentTimeMills() + message.isSenderSelf = isSenderSelf + + // Validate + var isValid = message.isValid() + // TODO: Legacy code: why this is check needed? + if (message is VisibleMessage && !isValid && proto.dataMessage.attachmentsCount != 0) { isValid = true } + if (!isValid) { + throw NonRetryableException("Invalid message") + } + + // Duplicate check + // TODO: Legacy code: this is most likely because we try to duplicate the message we just + // send (so that a new polling won't get the same message). At the moment it's the only reliable + // way to de-duplicate sent messages as we can add the "timestamp" before hand so that when + // message arrives back from server we can identify it. The logic can be removed if we can + // calculate message hash before sending it out so we can use the existing hash de-duplication + // mechanism. + if (storage.isDuplicateMessage(messageTimestampMs)) { + throw NonRetryableException("Duplicate message") + } + storage.addReceivedMessageTimestamp(messageTimestampMs) + + return message to proto + } + + + fun parse1o1Message( + data: ByteArray, + serverHash: String?, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val envelop = SessionProtocol.decodeFor1o1( + myEd25519PrivKey = currentUserEd25519PrivKey, + payload = data, + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + return parseMessage( + decodedEnvelope = envelop, + relaxSignatureCheck = false, + checkForBlockStatus = true, + isForGroup = false, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseGroupMessage( + data: ByteArray, + serverHash: String, + groupId: AccountId, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val keys = configFactory.withGroupConfigs(groupId) { + it.groupKeys.groupKeys() + } + + val decoded = SessionProtocol.decodeForGroup( + payload = data, + myEd25519PrivKey = currentUserEd25519PrivKey, + nowEpochMs = snodeClock.currentTimeMills(), + groupEd25519PublicKey = groupId.pubKeyBytes, + groupEd25519PrivateKeys = keys.toTypedArray(), + proBackendPubKey = proBackendKey + ) + + return parseMessage( + decodedEnvelope = decoded, + relaxSignatureCheck = false, + checkForBlockStatus = false, + isForGroup = true, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseCommunityMessage( + msg: OpenGroupApi.Message, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair? { + if (msg.data.isNullOrBlank()) { + return null + } + + val decoded = SessionProtocol.decodeForCommunity( + payload = Base64.decode(msg.data), + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + val sender = AccountId(msg.sessionId) + + return parseMessage( + contentPlaintext = decoded.contentPlainText.data, + relaxSignatureCheck = true, + checkForBlockStatus = false, + isForGroup = false, + currentUserId = currentUserId, + sender = sender, + messageTimestampMs = (msg.posted * 1000).toLong(), + currentUserBlindedIDs = currentUserBlindedIDs, + ).also { (message, _) -> + message.openGroupServerMessageID = msg.id + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 9e05ecce3e..3e0946027d 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -1,7 +1,7 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.util.BlindKeyAPI -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -13,6 +13,7 @@ import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Envelope @@ -21,9 +22,15 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.math.abs -object MessageReceiver { +@Deprecated("This class only exists so the old BatchMessageReceiver can function. New code should use MessageHandler directly.") +@Singleton +class MessageReceiver @Inject constructor( + private val storage: StorageProtocol, +) { internal sealed class Error(message: String) : Exception(message) { object DuplicateMessage: Error("Duplicate message.") @@ -60,7 +67,6 @@ object MessageReceiver { currentClosedGroups: Set?, closedGroupSessionId: String? = null, ): Pair { - val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() val isOpenGroupMessage = (openGroupServerID != null) var plaintext: ByteArray? = null @@ -91,7 +97,7 @@ object MessageReceiver { plaintext = decryptionResult.first sender = decryptionResult.second } else { - val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() + val userX25519KeyPair = storage.getUserX25519KeyPair() val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), userX25519KeyPair) plaintext = decryptionResult.first sender = decryptionResult.second @@ -105,10 +111,10 @@ object MessageReceiver { sender = envelope.source groupPublicKey = hexEncodedGroupPublicKey } else { - if (!MessagingModuleConfiguration.shared.storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { + if (!storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { throw Error.InvalidGroupPublicKey } - val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) + val encryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) if (encryptionKeyPairs.isEmpty()) { throw Error.NoGroupKeyPair } @@ -172,7 +178,7 @@ object MessageReceiver { } val isUserBlindedSender = sender == openGroupPublicKey?.let { BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, serverPubKey = Hex.fromStringCondensed(it), ) }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 0d43629fc9..d153a1606a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -33,12 +33,16 @@ class MessageRequestResponseHandler @Inject constructor( private val blindMappingRepository: BlindMappingRepository, ) { - suspend fun handleVisibleMessage(message: VisibleMessage) { + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: VisibleMessage + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return - val allBlindedAddresses = blindMappingRepository.calculateReverseMappings( - contactAddress = sender.address as Address.Standard - ) + val senderAddress = sender.address as Address.Standard + + val allBlindedAddresses = ctx?.getBlindIDMapping(senderAddress) + ?: blindMappingRepository.calculateReverseMappings(senderAddress) // Do we have an existing message request (including blinded requests)? val hasMessageRequest = configFactory.withUserConfigs { configs -> @@ -54,6 +58,7 @@ class MessageRequestResponseHandler @Inject constructor( if (hasMessageRequest) { handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -61,10 +66,14 @@ class MessageRequestResponseHandler @Inject constructor( } } - suspend fun handleExplicitRequestResponseMessage(message: MessageRequestResponse) { + fun handleExplicitRequestResponseMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: MessageRequestResponse + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return // Always handle explicit request response handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -81,8 +90,8 @@ class MessageRequestResponseHandler @Inject constructor( } } - private suspend fun fetchSenderAndReceiver(message: Message): Pair? { - val messageSender = recipientRepository.getRecipient( + private fun fetchSenderAndReceiver(message: Message): Pair? { + val messageSender = recipientRepository.getRecipientSync( requireNotNull(message.sender) { "MessageRequestResponse must have a sender" }.toAddress() @@ -92,7 +101,7 @@ class MessageRequestResponseHandler @Inject constructor( Log.e(TAG, "MessageRequestResponse sender must be a standard address, but got: ${messageSender.address.debugString}") null } else { - messageSender to recipientRepository.getRecipient( + messageSender to recipientRepository.getRecipientSync( requireNotNull(message.recipient) { "MessageRequestResponse must have a receiver" }.toAddress() @@ -101,6 +110,7 @@ class MessageRequestResponseHandler @Inject constructor( } private fun handleRequestResponse( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, messageSender: Recipient, messageReceiver: Recipient, messageTimestampMs: Long, @@ -164,7 +174,8 @@ class MessageRequestResponseHandler @Inject constructor( // Find all blinded conversations we have with this sender, move all the messages // from the blinded conversations to the standard conversation. - val blindedConversationAddresses = blindMappingRepository.calculateReverseMappings(messageSender.address) + val blindedConversationAddresses = (ctx?.getBlindIDMapping(messageSender.address) + ?: blindMappingRepository.calculateReverseMappings(messageSender.address)) .mapTo(hashSetOf()) { (c, id) -> Address.CommunityBlindedId( serverUrl = c.baseUrl, 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 0b5158ce5f..a35bc1fa95 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 @@ -1,7 +1,6 @@ package org.session.libsession.messaging.sending_receiving import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch @@ -9,10 +8,13 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.protocol.SessionProtocol import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.applyExpiryMode @@ -26,28 +28,34 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsignal.crypto.PushTransportDetails -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.defaultRequiresAuth -import org.session.libsignal.utilities.hasNamespaces -import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.service.ExpiringMessageManager import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote -object MessageSender { +@Singleton +class MessageSender @Inject constructor( + private val storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val recipientRepository: RecipientRepository, + private val messageDataProvider: MessageDataProvider, + private val messageSendJobFactory: MessageSendJob.Factory, + private val messageExpirationManager: ExpiringMessageManager, +) { // Error sealed class Error(val description: String) : Exception(description) { @@ -71,7 +79,7 @@ object MessageSender { // Convenience suspend fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean) { - return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { + return if (destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { sendToSnodeDestination(destination, message, isSyncMessage) @@ -81,9 +89,10 @@ object MessageSender { // One-on-One Chats & Closed Groups @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory val userPublicKey = storage.getUserPublicKey() + val userEd25519PrivKey = requireNotNull(storage.getUserED25519KeyPair()?.secretKey?.data) { + "Missing user key" + } // Set the timestamp, sender and recipient val messageSendTime = nowWithOffset if (message.sentTimestamp == null) { @@ -95,9 +104,9 @@ object MessageSender { // SHARED CONFIG when (destination) { is Destination.Contact -> message.recipient = destination.publicKey - is Destination.LegacyClosedGroup -> message.recipient = destination.groupPublicKey is Destination.ClosedGroup -> message.recipient = destination.publicKey - else -> throw IllegalStateException("Destination should not be an open group.") + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } val isSelfSend = (message.recipient == userPublicKey) @@ -133,59 +142,38 @@ object MessageSender { // Set the timestamp on the content so it can be verified against envelope timestamp proto.setSigTimestampMs(message.sentTimestamp!!) - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.build().toByteArray()) - - // Envelope information - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String - when (destination) { + val messageContent = when (destination) { is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.LegacyClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey - } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.publicKey + SessionProtocol.encodeFor1o1( + plaintext = proto.build().toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.publicKey), + proRotatingEd25519PrivKey = null, + ) } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.LegacyClosedGroup -> { - val encryptionKeyPair = - MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( - destination.groupPublicKey - )!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } - is Destination.ClosedGroup -> { - val envelope = MessageWrapper.createEnvelope(kind, message.sentTimestamp!!, senderPublicKey, proto.build().toByteArray()) - configFactory.withGroupConfigs(AccountId(destination.publicKey)) { - it.groupKeys.encrypt(envelope.toByteArray()) - } - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result using envelope information - val wrappedMessage = when (destination) { is Destination.ClosedGroup -> { - // encrypted bytes from the above closed group encryption and envelope steps - ciphertext + SessionProtocol.encodeForGroup( + plaintext = proto.build().toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + groupEd25519PublicKey = Hex.fromStringCondensed(destination.publicKey), + groupEd25519PrivateKey = configFactory.withGroupConfigs(AccountId(destination.publicKey)) { + it.groupKeys.groupEncKey() + }, + proRotatingEd25519PrivKey = null + ) } - else -> MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result return SnodeMessage( message.recipient!!, - base64EncodedData, + data = Base64.encodeBytes(messageContent), ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, messageSendTime ) @@ -193,8 +181,6 @@ object MessageSender { // One-on-One Chats & Closed Groups private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) = supervisorScope { - val configFactory = MessagingModuleConfiguration.shared.configFactory - // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) @@ -202,57 +188,39 @@ object MessageSender { try { val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) - // TODO: this might change in future for config messages - val forkInfo = SnodeAPI.forkInfo - val namespaces: List = when { - destination is Destination.LegacyClosedGroup - && forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP()) - - destination is Destination.LegacyClosedGroup - && forkInfo.hasNamespaces() -> listOf( - Namespace.UNAUTHENTICATED_CLOSED_GROUP(), - Namespace.DEFAULT - ()) - destination is Destination.ClosedGroup -> listOf(Namespace.GROUP_MESSAGES()) - - else -> listOf(Namespace.DEFAULT()) - } + val sendResult = runCatching { + when (destination) { + is Destination.ClosedGroup -> { + val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { + "Unable to authorize group message send" + } - val sendTasks = namespaces.map { namespace -> - if (destination is Destination.ClosedGroup) { - val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { - "Unable to authorize group message send" - } - - async { SnodeAPI.sendMessage( auth = groupAuth, message = snodeMessage, - namespace = namespace, + namespace = Namespace.GROUP_MESSAGES(), ) } - } else { - async { - SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = namespace) + is Destination.Contact -> { + SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) } + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") } } - val sendTaskResults = sendTasks.map { - runCatching { it.await() } - } - - val firstSuccess = sendTaskResults.firstOrNull { it.isSuccess }?.getOrNull() - if (firstSuccess != null) { - message.serverHash = firstSuccess.hash + if (sendResult.isSuccess) { + message.serverHash = sendResult.getOrThrow().hash handleSuccessfulMessageSend(message, destination, isSyncMessage) } else { - // If all tasks failed, throw the first exception - throw sendTaskResults.first().exceptionOrNull()!! + throw sendResult.exceptionOrNull()!! } } catch (exception: Exception) { - handleFailure(exception) + if (exception !is CancellationException) { + handleFailure(exception) + } + throw exception } } @@ -275,7 +243,7 @@ object MessageSender { return message.run { (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient) ?.let(Address::fromSerialized) - ?.let(MessagingModuleConfiguration.shared.recipientRepository::getRecipientSync) + ?.let(recipientRepository::getRecipientSync) ?.expiryMode ?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage } ?.expiryMillis @@ -285,8 +253,6 @@ object MessageSender { // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { message.sentTimestamp = nowWithOffset } @@ -296,10 +262,10 @@ object MessageSender { message.blocksMessageRequests = !configs.userProfile.getCommunityMessageRequests() } } - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! + val userEdKeyPair = storage.getUserED25519KeyPair()!! var serverCapabilities = listOf() var blindedPublicKey: ByteArray? = null - when(destination) { + when (destination) { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() storage.getOpenGroupPublicKey(destination.server)?.let { @@ -316,16 +282,9 @@ object MessageSender { serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), )?.pubKey?.data } - is Destination.LegacyOpenGroup -> { - serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() - storage.getOpenGroupPublicKey(destination.server)?.let { - blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - )?.pubKey?.data - } - } - else -> {} + + is Destination.ClosedGroup, + is Destination.Contact -> error("Destination must be an open group.") } val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey).hexString @@ -351,8 +310,11 @@ object MessageSender { if (message !is VisibleMessage || !message.isValid()) { throw Error.InvalidMessage } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) + val plaintext = SessionProtocol.encodeForCommunity( + plaintext = content.toByteArray(), + proRotatingEd25519PrivKey = null + ) + val openGroupMessage = OpenGroupMessage( sender = message.sender, sentTimestamp = message.sentTimestamp!!, @@ -378,13 +340,15 @@ object MessageSender { if (message !is VisibleMessage || !message.isValid()) { throw Error.InvalidMessage } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) - val ciphertext = MessageEncrypter.encryptBlinded( - plaintext, - destination.blindedPublicKey, - destination.serverPublicKey + val ciphertext = SessionProtocol.encodeForCommunityInbox( + plaintext = content.toByteArray(), + myEd25519PrivKey = userEdKeyPair.secretKey.data, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.blindedPublicKey), + communityServerPubKey = Hex.fromStringCondensed(destination.serverPublicKey), + proRotatingEd25519PrivKey = null, ) + val base64EncodedData = Base64.encodeBytes(ciphertext) val response = OpenGroupApi.sendDirectMessage( base64EncodedData, @@ -405,8 +369,7 @@ object MessageSender { } // Result Handling - fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { - val storage = MessagingModuleConfiguration.shared.storage + private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { val userPublicKey = storage.getUserPublicKey()!! // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) @@ -427,21 +390,9 @@ object MessageSender { storage.clearErrorMessage(messageId) // Track the open group server message ID - val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) + val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.OpenGroup) if (messageIsAddressedToCommunity) { - val address = when (destination) { - is Destination.LegacyOpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - is Destination.OpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - else -> { - throw Exception("Destination was a different destination than we were expecting") - } - } + val address = Address.Community(destination.server, destination.roomToken) val communityThreadID = storage.getThreadId(address) if (communityThreadID != null && communityThreadID >= 0) { storage.setOpenGroupServerMessageID( @@ -459,7 +410,7 @@ object MessageSender { storage.updateSentTimestamp(messageId, message.sentTimestamp!!) // Start the disappearing messages timer if needed - SSKEnvironment.shared.messageExpirationManager.onMessageSent(message) + messageExpirationManager.onMessageSent(message) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -483,12 +434,10 @@ object MessageSender { } fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { - val storage = MessagingModuleConfiguration.shared.storage - val messageId = message.id ?: return // no need to handle if message is marked as deleted - if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId)){ + if (messageDataProvider.isDeletedMessage(messageId)){ return } @@ -497,9 +446,7 @@ object MessageSender { } // Convenience - @JvmStatic fun send(message: VisibleMessage, address: Address, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val messageId = message.id if (messageId?.mms == true) { message.attachmentIDs.addAll(messageDataProvider.getAttachmentIDsFor(messageId.id)) @@ -517,24 +464,23 @@ object MessageSender { send(message, address) } - @JvmStatic @JvmOverloads fun send(message: Message, address: Address, statusCallback: SendChannel>? = null) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.applyExpiryMode(address) message.threadID = threadID val destination = Destination.from(address) - val job = MessagingModuleConfiguration.shared.messageSendJobFactory.create(message, destination, statusCallback) + val job = messageSendJobFactory.create(message, destination, statusCallback) JobQueue.shared.add(job) // if we are sending a 'Note to Self' make sure it is not hidden if( message is VisibleMessage && - address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && + address.toString() == storage.getUserPublicKey() && // only show the NTS if it is currently marked as hidden - MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } + configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } ){ // update config in case it was marked as hidden there - MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { + configFactory.withMutableUserConfigs { it.userProfile.setNtsPriority(PRIORITY_VISIBLE) } } @@ -547,7 +493,7 @@ object MessageSender { } suspend fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) sendNonDurably(message, destination, isSyncMessage) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 46ab9d7a48..bf891abf98 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -84,6 +84,7 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient?.blocked == true } +@Deprecated(replaceWith = ReplaceWith("ReceivedMessageProcessor"), message = "Use ReceivedMessageProcessor instead") @Singleton class ReceivedMessageHandler @Inject constructor( @param:ApplicationContext private val context: Context, @@ -131,7 +132,7 @@ class ReceivedMessageHandler @Inject constructor( } is DataExtractionNotification -> handleDataExtractionNotification(message) is UnsendRequest -> handleUnsendRequest(message) - is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(message) + is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(null, message) is VisibleMessage -> handleVisibleMessage( message = message, proto = proto, @@ -300,7 +301,7 @@ class ReceivedMessageHandler @Inject constructor( // Do nothing if the message was outdated if (messageIsOutdated(message, context.threadId)) { return null } - messageRequestResponseHandler.get().handleVisibleMessage(message) + messageRequestResponseHandler.get().handleVisibleMessage(null, message) // Handle group invite response if new closed group val threadRecipientAddress = context.threadAddress diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt new file mode 100644 index 0000000000..bbb3d408ea --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -0,0 +1,563 @@ +package org.session.libsession.messaging.sending_receiving + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.KeyPair +import okio.withLock +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.WebRtcUtils +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.BlindMappingRepository +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class ReceivedMessageProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val storage: Storage, + private val configFactory: ConfigFactoryProtocol, + private val threadDatabase: ThreadDatabase, + private val readReceiptManager: Provider, + private val typingIndicators: Provider, + private val prefs: TextSecurePreferences, + private val groupMessageHandler: Provider, + private val messageExpirationManager: Provider, + private val messageDataProvider: MessageDataProvider, + @param:ManagerScope private val scope: CoroutineScope, + private val notificationManager: MessageNotifier, + private val messageRequestResponseHandler: Provider, + private val visibleMessageHandler: Provider, + private val blindMappingRepository: BlindMappingRepository, + private val messageParser: MessageParser, +) { + private val threadMutexes = ConcurrentHashMap() + + + /** + * Start a message processing session, ensuring that thread updates and notifications are handled + * once the whole processing is complete. + * + * Note: the context passed to the block is not thread-safe, so it should not be shared between threads. + */ + fun startProcessing(debugName: String, block: (MessageProcessingContext) -> T): T { + val context = MessageProcessingContext() + val start = System.currentTimeMillis() + try { + return block(context) + } finally { + for (threadId in context.threadIDs.values) { + if (context.maxOutgoingMessageTimestamp > 0L && + context.maxOutgoingMessageTimestamp > storage.getLastSeen(threadId) + ) { + storage.markConversationAsRead( + threadId, + context.maxOutgoingMessageTimestamp, + force = true + ) + } + + storage.updateThread(threadId, true) + notificationManager.updateNotification(this.context, threadId) + } + + // Handle pending community reactions + context.pendingCommunityReactions?.let { reactions -> + storage.addReactions(reactions, replaceAll = true, notifyUnread = false) + reactions.clear() + } + + Log.d(TAG, "Processed messages for $debugName in ${System.currentTimeMillis() - start}ms") + } + } + + fun processSwarmMessage( + context: MessageProcessingContext, + threadAddress: Address.Conversable, + message: Message, + proto: SignalServiceProtos.Content, + ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + // The logic to check if the message should be discarded due to being from a hidden contact. + if (threadAddress is Address.Standard && + message.sentTimestamp != null && + shouldDiscardForHiddenContact( + ctx = context, + messageTimestamp = message.sentTimestamp!!, + threadAddress = threadAddress + ) + ) { + log { "Dropping message from hidden contact ${threadAddress.debugString}" } + return@withLock + } + + // Get or create thread ID, if we aren't allowed to create it, and it doesn't exist, drop the message + val threadId = context.threadIDs[threadAddress] ?: if (shouldCreateThread(message)) { + threadDatabase.getOrCreateThreadIdFor(threadAddress) + .also { context.threadIDs[threadAddress] = it } + } else { + threadDatabase.getThreadIdIfExistsFor(threadAddress) + .also { id -> + if (id == -1L) { + log { "Dropping message for non-existing thread ${threadAddress.debugString}" } + return@withLock + } else { + context.threadIDs[threadAddress] = id + } + } + } + + when (message) { + is ReadReceipt -> handleReadReceipt(message) + is TypingIndicator -> handleTypingIndicator(message) + is GroupUpdated -> groupMessageHandler.get().handleGroupUpdated( + message = message, + groupId = (threadAddress as? Address.Group)?.accountId + ) + + is ExpirationTimerUpdate -> { + // For groupsv2, there are dedicated mechanisms for handling expiration timers, and + // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. + if (threadAddress is Address.Group) { + Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") + } // also ignore it for communities since they do not support disappearing messages + else if (threadAddress is Address.Community) { + Log.d("MessageReceiver", "Ignoring expiration timer update for communities") + } else { + handleExpirationTimerUpdate(message) + } + } + + is DataExtractionNotification -> handleDataExtractionNotification(message) + is UnsendRequest -> handleUnsendRequest(message) + is MessageRequestResponse -> messageRequestResponseHandler.get() + .handleExplicitRequestResponseMessage(context, message) + + is VisibleMessage -> { + if (message.isSenderSelf && + message.sentTimestamp != null && + message.sentTimestamp!! > context.maxOutgoingMessageTimestamp + ) { + context.maxOutgoingMessageTimestamp = message.sentTimestamp!! + } + + visibleMessageHandler.get().handleVisibleMessage( + ctx = context, + message = message, + threadId = threadId, + threadAddress = threadAddress, + proto = proto, + runThreadUpdate = false, + runProfileUpdate = true, + ) + } + + is CallMessage -> handleCallMessage(message) + } + + } + + fun processCommunityInboxMessage( + context: MessageProcessingContext, + message: OpenGroupApi.DirectMessage + ) { + //TODO("Waiting for the implementation from libsession_util") + } + + fun processCommunityOutboxMessage( + context: MessageProcessingContext, + message: OpenGroupApi.DirectMessage + ) { + //TODO("Waiting for the implementation from libsession_util") + } + + fun processCommunityMessage( + context: MessageProcessingContext, + threadAddress: Address.Community, + message: OpenGroupApi.Message, + ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + var messageId = messageParser.parseCommunityMessage( + msg = message, + currentUserId = context.currentUserId, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + )?.let { (msg, proto) -> + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = msg, + proto = proto + ) + + msg.id + } + + // For community, we have a different way of handling reaction, this is outside of + // the normal enveloped message (even though enveloped message can also contain reaction, + // it's not used by anyone at the moment). + if (messageId == null) { + Log.d(TAG, "Handling reactions only message for community ${threadAddress.debugString}") + messageId = requireNotNull( + messageDataProvider.getMessageID( + serverId = message.id, + threadId = requireNotNull(storage.getThreadId(threadAddress)) { + "No thread ID for community ${threadAddress.debugString}" + } + )) { + "No message persisted for community message ${message.id}" + } + } + + val messageServerId = message.id.toString() + + for ((emoji, reaction) in message.reactions.orEmpty()) { + // We only really want up to 5 reactors per reaction to avoid excessive database load + // Among the 5 reactors, we must include ourselves if we reacted to this message + val otherReactorsToAdd = if (reaction.you) { + context.addPendingCommunityReaction( + messageId, + ReactionRecord( + messageId = messageId, + author = context.currentUserPublicKey, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = 0, + ) + ) + + val myBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + + reaction.reactors + .asSequence() + .filterNot { reactor -> reactor == context.currentUserPublicKey || myBlindedIDs.any { it.hexString == reactor } } + .take(4) + } else { + reaction.reactors + .asSequence() + .take(5) + } + + + for (reactor in otherReactorsToAdd) { + context.addPendingCommunityReaction( + messageId, + ReactionRecord( + messageId = messageId, + author = reactor, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = reaction.index, + ) + ) + } + } + } + + private fun handleReadReceipt(message: ReadReceipt) { + readReceiptManager.get().processReadReceipts( + message.sender!!, + message.timestamps!!, + message.receivedTimestamp!! + ) + } + + private fun handleTypingIndicator(message: TypingIndicator) { + when (message.kind!!) { + TypingIndicator.Kind.STARTED -> showTypingIndicatorIfNeeded(message.sender!!) + TypingIndicator.Kind.STOPPED -> hideTypingIndicatorIfNeeded(message.sender!!) + } + } + + private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + // We don't want to show other people's indicators if the toggle is off + if (!prefs.isTypingIndicatorsEnabled()) return + + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStartedMessage(threadID, address, 1) + } + + private fun hideTypingIndicatorIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStoppedMessage(threadID, address, 1, false) + } + + + /** + * Return true if this message should result in the creation of a thread. + */ + private fun shouldCreateThread(message: Message): Boolean { + return message is VisibleMessage + } + + private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { + messageExpirationManager.get().run { + insertExpirationTimerMessage(message) + onMessageReceived(message) + } + } + + private fun handleDataExtractionNotification(message: DataExtractionNotification) { + // We don't handle data extraction messages for groups (they shouldn't be sent, but just in case we filter them here too) + if (message.groupPublicKey != null) return + val senderPublicKey = message.sender!! + + val notification: DataExtractionNotificationInfoMessage = when (message.kind) { + is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage( + DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED + ) + + else -> return + } + storage.insertDataExtractionNotificationMessage( + senderPublicKey, + notification, + message.sentTimestamp!! + ) + } + + fun handleUnsendRequest(message: UnsendRequest): MessageId? { + val userPublicKey = storage.getUserPublicKey() + val userAuth = storage.userAuth ?: return null + val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> + var admin = false + val groupID = doubleEncodeGroupID(key) + val group = storage.getGroup(groupID) + if (group != null) { + admin = group.admins.map { it.toString() }.contains(message.sender) + } + admin + } ?: false + + // First we need to determine the validity of the UnsendRequest + // It is valid if: + val requestIsValid = + message.sender == message.author || // the sender is the author of the message + message.author == userPublicKey || // the sender is the current user + isLegacyGroupAdmin // sender is an admin of legacy group + + if (!requestIsValid) { + return null + } + + val timestamp = message.timestamp ?: return null + val author = message.author ?: return null + val messageToDelete = storage.getMessageByTimestamp(timestamp, author, false) ?: return null + val messageIdToDelete = messageToDelete.messageId + val messageType = messageToDelete.individualRecipient?.getType() + + // send a /delete rquest for 1on1 messages + if (messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once + try { + SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + } catch (e: Exception) { + Log.e("Loki", "Failed to delete message", e) + } + } + } + } + + // the message is marked as deleted locally + // except for 'note to self' where the message is completely deleted + if (messageType == MessageType.NOTE_TO_SELF) { + messageDataProvider.deleteMessage(messageIdToDelete) + } else { + messageDataProvider.markMessageAsDeleted( + messageIdToDelete, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } + + // delete reactions + storage.deleteReactions(messageToDelete.messageId) + + // update notification + if (!messageToDelete.isOutgoing) { + notificationManager.updateNotification(context) + } + + return messageIdToDelete + } + + private fun handleCallMessage(message: CallMessage) { + // TODO: refactor this out to persistence, just to help debug the flow and send/receive in synchronous testing + WebRtcUtils.SIGNAL_QUEUE.trySend(message) + } + + + /** + * Return true if the contact is marked as hidden for given message timestamp. + */ + private fun shouldDiscardForHiddenContact( + ctx: MessageProcessingContext, + messageTimestamp: Long, + threadAddress: Address.Standard + ): Boolean { + val hidden = configFactory.withUserConfigs { configs -> + configs.contacts.get(threadAddress.address)?.priority == ConfigBase.PRIORITY_HIDDEN + } + + return hidden && + // the message's sentTimestamp is earlier than the sentTimestamp of the last config + messageTimestamp < ctx.contactConfigTimestamp + } + + /** + * A context object for processing received messages. This object is mostly used to store + * expensive data that are only valid for the duration of a processing session. + * + * It also tracks some deferred updates that should be applied once processing is complete, + * such as thread updates, reactions, and notifications. + */ + inner class MessageProcessingContext { + private var recipients: HashMap? = null + val threadIDs: HashMap = hashMapOf() + private var currentUserBlindedKeysByCommunityServer: HashMap>? = null + val currentUserId: AccountId = AccountId(requireNotNull(storage.getUserPublicKey()) { + "No current user available" + }) + + var maxOutgoingMessageTimestamp: Long = 0L + + val currentUserEd25519KeyPair: KeyPair by lazy(LazyThreadSafetyMode.NONE) { + requireNotNull(storage.getUserED25519KeyPair()) { + "No current user ED25519 key pair available" + } + } + + val currentUserPublicKey: String get() = currentUserId.hexString + + + val contactConfigTimestamp: Long by lazy(LazyThreadSafetyMode.NONE) { + configFactory.getConfigTimestamp(UserConfigType.CONTACTS, currentUserPublicKey) + } + + private var blindIDMappingCache: HashMap>>? = + null + + + var pendingCommunityReactions: HashMap>? = null + private set + + + fun getBlindIDMapping(address: Address.Standard): List> { + val cache = blindIDMappingCache + ?: hashMapOf>>().also { + blindIDMappingCache = it + } + + return cache.getOrPut(address) { + blindMappingRepository.calculateReverseMappings(address) + } + } + + + fun getThreadRecipient(threadAddress: Address.Conversable): Recipient { + val cache = recipients ?: hashMapOf().also { + recipients = it + } + + return cache.getOrPut(threadAddress) { + recipientRepository.getRecipientSync(threadAddress) + } + } + + fun getCurrentUserBlindedIDsByServer(serverUrl: String): List { + val serverPubKey = requireNotNull(storage.getOpenGroupPublicKey(serverUrl)) { + "No open group public key found" + } + + val cache = + currentUserBlindedKeysByCommunityServer ?: hashMapOf>().also { + currentUserBlindedKeysByCommunityServer = it + } + + return cache.getOrPut(serverUrl) { + BlindKeyAPI.blind15Ids( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ).map(::AccountId) + AccountId( + BlindKeyAPI.blind25Id( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ) + ) + } + } + + + fun getCurrentUserBlindedIDsByThread(address: Address.Conversable): List { + if (address !is Address.Community) return emptyList() + return getCurrentUserBlindedIDsByServer(address.serverUrl) + } + + fun addPendingCommunityReaction(messageId: MessageId, reaction: ReactionRecord) { + val reactionsMap = pendingCommunityReactions + ?: hashMapOf>().also { + pendingCommunityReactions = it + } + + reactionsMap.getOrPut(messageId) { + mutableListOf() + }.add(reaction) + } + } + + companion object { + private const val TAG = "ReceivedMessageProcessor" + + private const val DEBUG_MESSAGE_PROCESSING = true + + private inline fun log(message: () -> String) { + if (DEBUG_MESSAGE_PROCESSING) { + Log.d(TAG, message()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt new file mode 100644 index 0000000000..06f9213bf0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -0,0 +1,258 @@ +package org.session.libsession.messaging.sending_receiving + +import android.text.TextUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsession.utilities.updateContact +import org.session.libsession.utilities.upsertContact +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager +import javax.inject.Inject +import javax.inject.Provider + +class VisibleMessageHandler @Inject constructor( + private val storage: Storage, + private val messageRequestResponseHandler: MessageRequestResponseHandler, + @param:ManagerScope private val scope: CoroutineScope, + private val groupManagerV2: GroupManagerV2, + private val messageDataProvider: MessageDataProvider, + private val proStatusManager: ProStatusManager, + private val configFactory: ConfigFactory, + private val profileUpdateHandler: Provider, + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, +){ + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext, + message: VisibleMessage, + threadId: Long, + threadAddress: Address.Conversable, + proto: SignalServiceProtos.Content, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean, + ): MessageId? { + val senderAddress = message.sender!!.toAddress() + + messageRequestResponseHandler.handleVisibleMessage(ctx, message) + + // Handle group invite response if new closed group + if (threadAddress is Address.Group && senderAddress is Address.Standard) { + scope.launch { + try { + groupManagerV2 + .handleInviteResponse( + threadAddress.accountId, + senderAddress.accountId, + approved = true + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to handle invite response", e) + } + } + } + // Parse quote if needed + var quoteModel: QuoteModel? = null + var quoteMessageBody: String? = null + if (message.quote != null && proto.dataMessage.hasQuote()) { + val quote = proto.dataMessage.quote + + var author = quote.author.toAddress() + + if (author is Address.WithAccountId && author.accountId in ctx.getCurrentUserBlindedIDsByThread(threadAddress)) { + author = Address.Standard(ctx.currentUserId) + } + + val messageInfo = messageDataProvider.getMessageForQuote(threadId, quote.id, author) + quoteMessageBody = messageInfo?.third + quoteModel = if (messageInfo != null) { + val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() + QuoteModel(quote.id, author,null,false, attachments) + } else { + QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList)) + } + } + // Parse link preview if needed + val linkPreviews: MutableList = mutableListOf() + if (message.linkPreview != null && proto.dataMessage.previewCount > 0) { + for (preview in proto.dataMessage.previewList) { + val thumbnail = PointerAttachment.forPointer(preview.image) + val url = Optional.fromNullable(preview.url) + val title = Optional.fromNullable(preview.title) + val hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent + if (hasContent) { + val linkPreview = LinkPreview(url.get(), title.or(""), thumbnail) + linkPreviews.add(linkPreview) + } else { + Log.w("Loki", "Discarding an invalid link preview. hasContent: $hasContent") + } + } + } + // Parse attachments if needed + val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } + + // Cancel any typing indicators if needed + cancelTypingIndicatorsIfNeeded(message.sender!!) + + // Parse reaction if needed + val threadIsGroup = threadAddress.isGroupOrCommunity + message.reaction?.let { reaction -> + if (reaction.react == true) { + reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() + reaction.dateSent = message.sentTimestamp ?: 0 + reaction.dateReceived = message.receivedTimestamp ?: 0 + storage.addReaction( + threadId = threadId, + reaction = reaction, + messageSender = senderAddress.address, + notifyUnread = !threadIsGroup + ) + } else { + storage.removeReaction( + emoji = reaction.emoji!!, + messageTimestamp = reaction.timestamp!!, + threadId = threadId, + author = senderAddress.address, + notifyUnread = threadIsGroup + ) + } + } ?: run { + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + + // Verify the incoming message length and truncate it if needed, before saving it to the db + val maxChars = proStatusManager.getIncomingMessageMaxLength(message) + val messageText = message.text?.take(maxChars) // truncate to max char limit for this message + message.text = messageText + message.hasMention = (sequenceOf(ctx.currentUserPublicKey) + ctx.getCurrentUserBlindedIDsByThread(threadAddress).asSequence()) + .any { key -> + messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") + } + + // Persist the message + message.threadID = threadId + + // clean up the message - For example we do not want any expiration data on messages for communities + if(message.openGroupServerMessageID != null){ + message.expiryMode = ExpiryMode.NONE + } + + val threadRecipient = ctx.getThreadRecipient(threadAddress) + val messageID = storage.persist( + threadRecipient = threadRecipient, + message = message, + quotes = quoteModel, + linkPreview = linkPreviews, + attachments = attachments, + runThreadUpdate = runThreadUpdate + ) ?: return null + + // If we have previously "hidden" the sender, we should flip the flag back to visible + if (senderAddress is Address.Standard && senderAddress.address != ctx.currentUserPublicKey) { + val existingContact = + configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } + + if (existingContact != null && existingContact.priority == PRIORITY_HIDDEN) { + Log.d(TAG, "Flipping thread for ${senderAddress.debugString} to visible") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.updateContact(senderAddress) { + priority = PRIORITY_VISIBLE + } + } + } else if (existingContact == null || !existingContact.approvedMe) { + // If we don't have the contact, create a new one with approvedMe = true + Log.d(TAG, "Creating new contact for ${senderAddress.debugString} with approvedMe = true") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(senderAddress) { + approvedMe = true + } + } + } + } + + // Update profile if needed: + // - must be done after the message is persisted) + // - must be done after neccessary contact is created + if (runProfileUpdate && senderAddress is Address.WithAccountId) { + val updates = ProfileUpdateHandler.Updates.create( + name = message.profile?.displayName, + picUrl = message.profile?.profilePictureURL, + picKey = message.profile?.profileKey, + blocksCommunityMessageRequests = message.blocksMessageRequests, + proStatus = null, + profileUpdateTime = message.profile?.profileUpdated, + ) + + if (updates != null) { + profileUpdateHandler.get().handleProfileUpdate( + senderId = senderAddress.accountId, + updates = updates, + fromCommunity = (threadRecipient.data as? RecipientData.Community)?.let { data -> + BaseCommunityInfo(baseUrl = data.serverUrl, room = data.room, pubKeyHex = data.serverPubKey) + }, + ) + } + } + + // Parse & persist attachments + // Start attachment downloads if needed + if (messageID.mms && (threadRecipient.autoDownloadAttachments == true || senderAddress.address == ctx.currentUserPublicKey)) { + storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> + attachment.attachmentId?.let { id -> + JobQueue.shared.add( + attachmentDownloadJobFactory.create( + attachmentID = id.rowId, + mmsMessageId = messageID.id + )) + } + } + } + message.openGroupServerMessageID?.let { + storage.setOpenGroupServerMessageID( + messageID = messageID, + serverID = it, + threadID = threadId + ) + } + message.id = messageID + messageExpirationManager.onMessageReceived(message) + return messageID + } + return null + } + + private fun cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveIncomingMessage(threadID, address, 1) + } + + companion object { + private const val TAG = "VisibleMessageHandler" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index a6c56bb47c..9ad9213197 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.sending_receiving.pollers import com.fasterxml.jackson.core.type.TypeReference -import com.google.protobuf.ByteString import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,14 +17,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob import org.session.libsession.messaging.messages.Message.Companion.senderOrSync -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequest @@ -33,26 +28,19 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequestInf import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchResponse import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupApi.DirectMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi.Message import org.session.libsession.messaging.open_groups.OpenGroupApi.getOrFetchServerCapabilities import org.session.libsession.messaging.open_groups.OpenGroupApi.parallelBatch -import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.sending_receiving.MessageReceiver -import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP.Verb.GET import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.CommunityDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager -import java.util.concurrent.TimeUnit private typealias PollRequestToken = Channel>> @@ -67,14 +55,12 @@ private typealias PollRequestToken = Channel>> class OpenGroupPoller @AssistedInject constructor( private val storage: StorageProtocol, private val appVisibilityManager: AppVisibilityManager, - private val blindMappingRepository: BlindMappingRepository, - private val receivedMessageHandler: ReceivedMessageHandler, - private val batchMessageJobFactory: BatchMessageReceiveJob.Factory, private val configFactory: ConfigFactoryProtocol, - private val threadDatabase: ThreadDatabase, private val trimThreadJobFactory: TrimThreadJob.Factory, private val openGroupDeleteJobFactory: OpenGroupDeleteJob.Factory, private val communityDatabase: CommunityDatabase, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted private val server: String, @Assisted private val scope: CoroutineScope, @Assisted private val pollerSemaphore: Semaphore, @@ -168,8 +154,6 @@ class OpenGroupPoller @AssistedInject constructor( return emptyList() } - val publicKey = allCommunities.first { it.community.baseUrl == server }.community.pubKeyHex - poll(rooms) .asSequence() .filterNot { it.body == null } @@ -179,16 +163,16 @@ class OpenGroupPoller @AssistedInject constructor( handleRoomPollInfo(Address.Community(server, response.endpoint.roomToken), response.body as Map<*, *>) } is Endpoint.RoomMessagesRecent -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.RoomMessagesSince -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.Inbox, is Endpoint.InboxSince -> { - handleDirectMessages(server, false, response.body as List) + handleInboxMessages( response.body as List) } is Endpoint.Outbox, is Endpoint.OutboxSince -> { - handleDirectMessages(server, true, response.body as List) + handleOutboxMessages( response.body as List) } else -> { /* We don't care about the result of any other calls (won't be polled for) */} } @@ -228,7 +212,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/recent?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesRecent(room), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } else { BatchRequestInfo( @@ -237,7 +221,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/since/$lastMessageServerId?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } ) @@ -294,136 +278,104 @@ class OpenGroupPoller @AssistedInject constructor( private fun handleMessages( - server: String, roomToken: String, messages: List ) { - val sortedMessages = messages.sortedBy { it.seqno } - sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> - storage.setLastMessageServerID(roomToken, server, seqNo) - } - val (deletions, additions) = sortedMessages.partition { it.deleted } - handleNewMessages(server, roomToken, additions.map { - OpenGroupMessage( - serverID = it.id, - sender = it.sessionId, - sentTimestamp = (it.posted * 1000).toLong(), - base64EncodedData = it.data, - base64EncodedSignature = it.signature, - reactions = it.reactions - ) - }) - handleDeletedMessages(server, roomToken, deletions.map { it.id }) - } - - private suspend fun handleDirectMessages( - server: String, - fromOutbox: Boolean, - messages: List - ) { - if (messages.isEmpty()) return - val serverPublicKey = storage.getOpenGroupPublicKey(server)!! - val sortedMessages = messages.sortedBy { it.id } - val lastMessageId = sortedMessages.last().id - if (fromOutbox) { - storage.setLastOutboxMessageId(server, lastMessageId) - } else { - storage.setLastInboxMessageId(server, lastMessageId) - } - sortedMessages.forEach { - val encodedMessage = Base64.decode(it.message) - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setTimestampMs(TimeUnit.SECONDS.toMillis(it.postedAt)) - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setContent(ByteString.copyFrom(encodedMessage)) - .setSource(it.sender) - .build() - try { - val (message, proto) = MessageReceiver.parse( - envelope.toByteArray(), - null, - fromOutbox, - if (fromOutbox) it.recipient else it.sender, - serverPublicKey, - emptySet() // this shouldn't be necessary as we are polling open groups here - ) - if (fromOutbox) { - val syncTarget = blindMappingRepository.getMapping( - serverUrl = server, - blindedAddress = Address.Blinded(AccountId(it.recipient)) - )?.accountId?.hexString ?: it.recipient - - if (message is VisibleMessage) { - message.syncTarget = syncTarget - } else if (message is ExpirationTimerUpdate) { - message.syncTarget = syncTarget - } - } - val threadAddress = when (val addr = message.senderOrSync.toAddress()) { - is Address.Blinded -> Address.CommunityBlindedId(serverUrl = server, blindedId = addr) - is Address.Conversable -> addr - else -> throw IllegalArgumentException("Unsupported address type: ${addr.debugString}") - } + val (deletions, additions) = messages.partition { it.deleted } - val threadId = threadDatabase.getThreadIdIfExistsFor(threadAddress) - receivedMessageHandler.handle( - message = message, - proto = proto, - threadId = threadId, - threadAddress = threadAddress, - ) - } catch (e: Exception) { - Log.e(TAG, "Couldn't handle direct message", e) - } - } - } - - private fun handleNewMessages(server: String, roomToken: String, messages: List) { val threadAddress = Address.Community(serverUrl = server, room = roomToken) // check thread still exists val threadId = storage.getThreadId(threadAddress) ?: return - val envelopes = mutableListOf?>>() - messages.sortedBy { it.serverID!! }.forEach { message -> - if (!message.base64EncodedData.isNullOrEmpty()) { - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setSource(message.sender!!) - .setSourceDevice(1) - .setContent(message.toProto().toByteString()) - .setTimestampMs(message.sentTimestamp) - .build() - envelopes.add(Triple( message.serverID, envelope, message.reactions)) - } - } - envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> - val parameters = list.map { (serverId, message, reactions) -> - MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId, reactions = reactions) + if (additions.isNotEmpty()) { + receivedMessageProcessor.startProcessing("CommunityPoller(${threadAddress.debugString})") { ctx -> + for (msg in additions.sortedBy { it.seqno }) { + try { + // Set the last message server ID to each message as we process them, so that if processing fails halfway through, + // we don't re-process messages we've already handled. + storage.setLastMessageServerID(roomToken, server, msg.seqno) + + receivedMessageProcessor.processCommunityMessage( + context = ctx, + threadAddress = threadAddress, + message = msg, + ) + } catch (e: Exception) { + Log.e( + TAG, + "Error processing open group message ${msg.id} in ${threadAddress.debugString}", + e + ) + } + } } - JobQueue.shared.add(batchMessageJobFactory.create( - parameters, - fromCommunity = threadAddress - )) - } - if (envelopes.isNotEmpty()) { JobQueue.shared.add(trimThreadJobFactory.create(threadId)) } - } - - private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List) { - val threadID = storage.getThreadId(Address.Community(serverUrl = server, room = roomToken)) ?: return - if (serverIds.isNotEmpty()) { + if (deletions.isNotEmpty()) { JobQueue.shared.add( openGroupDeleteJobFactory.create( - messageServerIds = serverIds.toLongArray(), - threadId = threadID + messageServerIds = LongArray(deletions.size) { i -> deletions[i].id }, + threadId = threadId ) ) } } + /** + * Handle messages that are sent to us directly. + */ + private fun handleInboxMessages( + messages: List + ) { + if (messages.isEmpty()) return + val sorted = messages.sortedBy { it.postedAt } + + receivedMessageProcessor.startProcessing("CommunityInbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastInboxMessageId(server, sorted.last().id) + + receivedMessageProcessor.processCommunityInboxMessage( + context = ctx, + message = apiMessage, + ) + + } catch (e: Exception) { + Log.e(TAG, "Error processing inbox message", e) + } + } + } + } + + /** + * Handle messages that we have sent out to others. + */ + private fun handleOutboxMessages( + messages: List + ) { + if (messages.isEmpty()) return + val sorted = messages.sortedBy { it.postedAt } + + receivedMessageProcessor.startProcessing("CommunityOutbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastOutboxMessageId(server, sorted.last().id) + + receivedMessageProcessor.processCommunityOutboxMessage( + context = ctx, + message = apiMessage, + ) + + } catch (e: Exception) { + Log.e(TAG, "Error processing inbox message", e) + } + } + } + } + + sealed interface PollState { data class Idle(val lastPolled: Result>?) : PollState data object Polling : PollState diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 13dfeea206..160137f53a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -27,21 +27,23 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.Namespace import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.time.Duration.Companion.days @@ -57,7 +59,10 @@ class Poller @AssistedInject constructor( private val preferences: TextSecurePreferences, private val appVisibilityManager: AppVisibilityManager, private val networkConnectivity: NetworkConnectivity, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val snodeClock: SnodeClock, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val processor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -119,7 +124,7 @@ class Poller @AssistedInject constructor( // To migrate to multi part config, we'll need to fetch all the config messages so we // get the chance to process those multipart messages again... lokiApiDatabase.clearLastMessageHashesByNamespaces(*allConfigNamespaces) - lokiApiDatabase.clearReceivedMessageHashValuesByNamespaces(*allConfigNamespaces) + receivedMessageHashDatabase.removeAllByNamespaces(*allConfigNamespaces) preferences.migratedToMultiPartConfig = true } @@ -208,60 +213,91 @@ class Poller @AssistedInject constructor( } } - private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { - val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + private fun processPersonalMessages(messages: List) { + if (messages.isEmpty()) { + Log.d(TAG, "No personal messages to process") + return } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } - } - private fun processConfig(snode: Snode, rawMessages: RawResponse, forConfig: UserConfigType) { - Log.d(TAG, "Received ${rawMessages.size} messages for $forConfig") - val messages = rawMessages["messages"] as? List<*> - val namespace = forConfig.namespace - val processed = if (!messages.isNullOrEmpty()) { - SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) - SnodeAPI.removeDuplicates( - publicKey = userPublicKey, - messages = messages, - messageHashGetter = { (it as? Map<*, *>)?.get("hash") as? String }, - namespace = namespace, - updateStoredHashes = true - ).mapNotNull { rawMessageAsJSON -> - rawMessageAsJSON as Map<*, *> // removeDuplicates should have ensured this is always a map - val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null - val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null - val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset - val body = Base64.decode(b64EncodedBody) - ConfigMessage(data = body, hash = hashValue, timestamp = timestamp) + Log.d(TAG, "Received ${messages.size} personal messages from snode") + + processor.startProcessing("Poller") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = Namespace.DEFAULT(), + hash = message.hash + )) { + Log.d(TAG, "Skipping duplicated message ${message.hash}") + continue + } + + try { + val (message, proto) = messageParser.parse1o1Message( + data = message.data, + serverHash = message.hash, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + currentUserId = ctx.currentUserId + ) + + processor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } catch (ec: Exception) { + Log.e( + TAG, + "Error while processing personal message with hash ${message.hash}", + ec + ) + } } - } else emptyList() + } + } - Log.d(TAG, "About to process ${processed.size} messages for $forConfig") + private fun processConfig(messages: List, forConfig: UserConfigType) { + if (messages.isEmpty()) { + Log.d(TAG, "No messages to process for $forConfig") + return + } - if (processed.isEmpty()) return + val newMessages = messages + .asSequence() + .filterNot { msg -> + receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = forConfig.namespace, + hash = msg.hash + ) + } + .map { m-> + ConfigMessage( + data = m.data, + hash = m.hash, + timestamp = m.timestamp.toEpochMilli() + ) + } + .toList() - try { - configFactory.mergeUserConfigs( - userConfigType = forConfig, - messages = processed, - ) - } catch (e: Exception) { - Log.e(TAG, "Error while merging user configs", e) + if (newMessages.isNotEmpty()) { + try { + configFactory.mergeUserConfigs( + userConfigType = forConfig, + messages = newMessages + ) + } catch (e: Exception) { + Log.e(TAG, "Error while merging user configs for $forConfig", e) + } } - Log.d(TAG, "Completed processing messages for $forConfig") + Log.d(TAG, "Processed ${newMessages.size} new messages for config $forConfig") } private suspend fun poll(snode: Snode, pollOnlyUserProfileConfig: Boolean) = supervisorScope { - val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + val userAuth = requireNotNull(storage.userAuth) // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { @@ -280,7 +316,7 @@ class Poller @AssistedInject constructor( snode = snode, publicKey = userPublicKey, request = request, - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } } @@ -314,7 +350,12 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest(snode, userPublicKey, request, Map::class.java) + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = userPublicKey, + request = request, + responseType = RetrieveMessageResponse.serializer() + ) } } } @@ -329,7 +370,7 @@ class Poller @AssistedInject constructor( SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, - newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, extend = true ) ) @@ -350,7 +391,18 @@ class Poller @AssistedInject constructor( continue } - processConfig(snode, result.getOrThrow(), configType) + val messages = result.getOrThrow().messages + processConfig(messages = messages, forConfig = configType) + + if (messages.isNotEmpty()) { + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = messages + .maxBy { it.timestamp }.hash, + namespace = configType.namespace + ) + } } // Process the messages if we requested them @@ -359,7 +411,17 @@ class Poller @AssistedInject constructor( if (result.isFailure) { Log.e(TAG, "Error while fetching messages", result.exceptionOrNull()) } else { - processPersonalMessages(snode, result.getOrThrow()) + val messages = result.getOrThrow().messages + processPersonalMessages(messages) + + messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = newest.hash, + namespace = Namespace.DEFAULT() + ) + } } } } diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index bab6cd0c1a..3fa4a53f15 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -1,83 +1,14 @@ package org.session.libsession.messaging.utilities -import com.google.protobuf.ByteString import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.protos.WebSocketProtos.WebSocketMessage -import org.session.libsignal.protos.WebSocketProtos.WebSocketRequestMessage -import org.session.libsignal.utilities.Log -import java.security.SecureRandom object MessageWrapper { - // region Types - sealed class Error(val description: String) : Exception(description) { - object FailedToWrapData : Error("Failed to wrap data.") - object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.") - object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.") - object FailedToUnwrapData : Error("Failed to unwrap data.") - } - // endregion - - // region Wrapping - /** - * Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application. - */ - fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray { - try { - val envelope = createEnvelope(type, timestamp, senderPublicKey, content) - val webSocketMessage = createWebSocketMessage(envelope) - return webSocketMessage.toByteArray() - } catch (e: Exception) { - throw if (e is Error) e else Error.FailedToWrapData - } - } - - fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope { - try { - val builder = Envelope.newBuilder() - builder.type = type - builder.timestampMs = timestamp - builder.source = senderPublicKey - builder.sourceDevice = 1 - builder.content = ByteString.copyFrom(content) - return builder.build() - } catch (e: Exception) { - Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.") - throw Error.FailedToWrapMessageInEnvelope - } - } - - private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage { - try { - return WebSocketMessage.newBuilder().apply { - request = WebSocketRequestMessage.newBuilder().apply { - verb = "PUT" - path = "/api/v1/message" - id = SecureRandom().nextLong() - body = envelope.toByteString() - }.build() - type = WebSocketMessage.Type.REQUEST - }.build() - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to wrap envelope in web socket message: ${e.message}.") - throw Error.FailedToWrapEnvelopeInWebSocketMessage - } - } - // endregion - - // region Unwrapping - /** - * `data` shouldn't be base 64 encoded. - */ fun unwrap(data: ByteArray): Envelope { - try { - val webSocketMessage = WebSocketMessage.parseFrom(data) - val envelopeAsData = webSocketMessage.request.body - return Envelope.parseFrom(envelopeAsData) - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to unwrap data", e) - throw Error.FailedToUnwrapData - } + val webSocketMessage = WebSocketMessage.parseFrom(data) + val envelopeAsData = webSocketMessage.request.body + return Envelope.parseFrom(envelopeAsData) } // endregion } diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 7b471a216c..b227885f7e 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,7 +3,6 @@ package org.session.libsession.snode import android.os.SystemClock -import com.fasterxml.jackson.databind.JsonNode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,20 +11,20 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map -import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.snode.utilities.asyncPromise @@ -37,17 +36,13 @@ import org.session.libsession.utilities.toByteArray import org.session.libsignal.crypto.secureRandom import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.retryWithUniformInterval import java.util.Locale import kotlin.collections.component1 @@ -165,13 +160,18 @@ object SnodeAPI { method: Snode.Method, snode: Snode, parameters: Map, - responseClass: Class, + responseDeserializationStrategy: DeserializationStrategy, publicKey: String? = null, version: Version = Version.V3 ): Res = when { useOnionRequests -> { val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await() - JsonUtil.fromJson(resp.body ?: throw Error.Generic, responseClass) + (resp.body ?: throw Error.Generic).inputStream().use { inputStream -> + MessagingModuleConfiguration.shared.json.decodeFromStream( + deserializer = responseDeserializationStrategy, + stream = inputStream + ) + } } else -> HTTP.execute( @@ -182,7 +182,10 @@ object SnodeAPI { this["params"] = parameters } ).toString().let { - JsonUtil.fromJson(it, responseClass) + MessagingModuleConfiguration.shared.json.decodeFromString( + deserializer = responseDeserializationStrategy, + string = it + ) } } @@ -355,49 +358,6 @@ object SnodeAPI { } } - /** - * Retrieve messages from the swarm. - * - * @param snode The swarm service where you want to retrieve messages from. It can be a swarm for a specific user or a group. Call [getSingleTargetSnode] to get a swarm node. - * @param auth The authentication data required to retrieve messages. This can be a user or group authentication data. - * @param namespace The namespace of the messages you want to retrieve. Default is 0. - */ - fun getRawMessages( - snode: Snode, - auth: SwarmAuth, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" } - ) { - put( - "last_hash", - database.getLastMessageHashValue(snode, auth.accountId.hexString, namespace).orEmpty() - ) - } - - // Make the request - return invoke(Snode.Method.Retrieve, snode, parameters, auth.accountId.hexString) - } - - fun getUnauthenticatedRawMessages( - snode: Snode, - publicKey: String, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildMap { - put("last_hash", database.getLastMessageHashValue(snode, publicKey, namespace).orEmpty()) - put("pubkey", publicKey) - if (namespace != 0) { - put("namespace", namespace) - } - } - - return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) - } - fun buildAuthenticatedStoreBatchInfo( namespace: Int, message: SnodeMessage, @@ -541,40 +501,11 @@ object SnodeAPI { ) } - @Suppress("UNCHECKED_CAST") - fun getRawBatchResponse( - snode: Snode, - publicKey: String, - requests: List, - sequence: Boolean = false - ): RawResponsePromise { - val parameters = buildMap { this["requests"] = requests } - return invoke( - if (sequence) Snode.Method.Sequence else Snode.Method.Batch, - snode, - parameters, - publicKey - ).success { rawResponses -> - rawResponses[KEY_RESULTS].let { it as List } - .asSequence() - .filter { it[KEY_CODE] as? Int != 200 } - .forEach { response -> - Log.w("Loki", "response code was not 200") - handleSnodeError( - response[KEY_CODE] as? Int ?: 0, - response[KEY_BODY] as? Map<*, *>, - snode, - publicKey - ) - } - } - } - private data class RequestInfo( val snode: Snode, val publicKey: String, val request: SnodeBatchRequestInfo, - val responseType: Class<*>, + val responseType: DeserializationStrategy<*>, val callback: SendChannel>, val requestTime: Long = SystemClock.elapsedRealtime(), ) @@ -641,7 +572,8 @@ object SnodeAPI { throw BatchResponse.Error(resp) } - JsonUtil.fromJson(resp.body, req.responseType) + MessagingModuleConfiguration.shared.json.decodeFromJsonElement( + req.responseType, resp.body)!! } runCatching { @@ -664,7 +596,7 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - responseType: Class, + responseType: DeserializationStrategy, ): T { val callback = Channel>(capacity = 1) @Suppress("UNCHECKED_CAST") @@ -689,8 +621,8 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - ): JsonNode { - return sendBatchRequest(snode, publicKey, request, JsonNode::class.java) + ): JsonElement { + return sendBatchRequest(snode, publicKey, request, JsonElement.serializer()) } suspend fun getBatchResponse( @@ -703,7 +635,7 @@ object SnodeAPI { method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode = snode, parameters = mapOf("requests" to requests), - responseClass = BatchResponse::class.java, + responseDeserializationStrategy = BatchResponse.serializer(), publicKey = publicKey ).also { resp -> // If there's a unsuccessful response, go through specific logic to handle @@ -712,8 +644,8 @@ object SnodeAPI { if (firstError != null) { handleSnodeError( statusCode = firstError.code, - json = if (firstError.body.isObject) { - JsonUtil.fromJson(firstError.body, Map::class.java) + json = if (firstError.body is JsonObject) { + JsonUtil.fromJson(firstError.body.toString(), Map::class.java) } else { null }, @@ -724,30 +656,6 @@ object SnodeAPI { } } - fun getExpiries( - messageHashes: List, - auth: SwarmAuth, - ): RawResponsePromise { - val hashes = messageHashes.takeIf { it.size != 1 } - ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. - return scope.retrySuspendAsPromise(maxRetryCount) { - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> buildString { - append(Snode.Method.GetExpiries.rawValue) - append(t) - hashes.forEach(this::append) - } }, - ) { - this["messages"] = hashes - } - - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - invoke(Snode.Method.GetExpiries, snode, params, auth.accountId.hexString).await() - } - } - fun alterTtl( auth: SwarmAuth, messageHashes: List, @@ -790,12 +698,6 @@ object SnodeAPI { } } - fun getMessages(auth: SwarmAuth): MessageListPromise = scope.retrySuspendAsPromise(maxRetryCount) { - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - val resp = getRawMessages(snode, auth).await() - parseRawMessagesResponse(resp, snode, auth.accountId.hexString) - } - fun getNetworkTime(snode: Snode): Promise, Exception> = invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 @@ -845,7 +747,7 @@ object SnodeAPI { params = params, namespace = namespace ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } @@ -959,89 +861,6 @@ object SnodeAPI { ) } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true, decrypt: ((ByteArray) -> Pair?)? = null): List> = - (rawResponse["messages"] as? List<*>)?.let { messages -> - if (updateLatestHash) updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - removeDuplicates( - publicKey = publicKey, - messages = parseEnvelopes(messages, decrypt), - messageHashGetter = { it.second }, - namespace = namespace, - updateStoredHashes = updateStoredHashes - ) - } ?: listOf() - - fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { - val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> - val hashValue = lastMessageAsJSON?.get("hash") as? String - when { - hashValue != null -> database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) - rawMessages.isNotEmpty() -> Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") - } - } - - /** - * - * - * TODO Use a db transaction, synchronizing is sufficient for now because - * database#setReceivedMessageHashValues is only called here. - */ - @Synchronized - fun removeDuplicates( - publicKey: String, - messages: List, - messageHashGetter: (M) -> String?, - namespace: Int, - updateStoredHashes: Boolean - ): List { - val hashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() - return messages - .filter { message -> - val hash = messageHashGetter(message) - if (hash == null) { - Log.d("Loki", "Missing hash value for message: ${message?.prettifiedDescription()}.") - return@filter false - } - - val isNew = hashValues.add(hash) - - if (!isNew) { - Log.d("Loki", "Duplicate message hash: $hash.") - } - - isNew - } - .also { - if (updateStoredHashes && it.isNotEmpty()) { - database.setReceivedMessageHashValues(publicKey, hashValues, namespace) - } - } - } - - private fun parseEnvelopes(rawMessages: List<*>, decrypt: ((ByteArray)->Pair?)?): List> { - return rawMessages.mapNotNull { rawMessage -> - val rawMessageAsJSON = rawMessage as? Map<*, *> - val base64EncodedData = rawMessageAsJSON?.get("data") as? String - val data = base64EncodedData?.let { Base64.decode(it) } - if (data != null) { - try { - if (decrypt != null) { - val (decrypted, sender) = decrypt(data)!! - val envelope = SignalServiceProtos.Envelope.parseFrom(decrypted).toBuilder() - envelope.source = sender.hexString - Pair(envelope.build(), rawMessageAsJSON["hash"] as? String) - } - else Pair(MessageWrapper.unwrap(data), rawMessageAsJSON["hash"] as? String) - } catch (e: Exception) { - Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.", e) - null - } - } else { - Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.") - null - } - } - } @Suppress("UNCHECKED_CAST") private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = @@ -1111,5 +930,4 @@ object SnodeAPI { // Type Aliases typealias RawResponse = Map<*, *> -typealias MessageListPromise = Promise>, Exception> typealias RawResponsePromise = Promise diff --git a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt index 723abfc79f..d3fa2acd19 100644 --- a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt +++ b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt @@ -1,15 +1,15 @@ package org.session.libsession.snode.model -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement -data class BatchResponse @JsonCreator constructor( - @param:JsonProperty("results") val results: List, -) { - data class Item @JsonCreator constructor( - @param:JsonProperty("code") val code: Int, - @param:JsonProperty("body") val body: JsonNode, +@Serializable + +data class BatchResponse(val results: List, ) { + @Serializable + data class Item( + val code: Int, + val body: JsonElement, ) { val isSuccessful: Boolean get() = code in 200..299 diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt index b9173e1462..2c7b6f6d7e 100644 --- a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt +++ b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt @@ -1,41 +1,45 @@ package org.session.libsession.snode.model import android.util.Base64 -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.util.StdConverter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant -data class StoreMessageResponse @JsonCreator constructor( - @JsonProperty("hash") val hash: String, - @JsonProperty("t") val timestamp: Long, +@Serializable +data class StoreMessageResponse( + val hash: String, + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") val timestamp: Instant, ) -class RetrieveMessageResponse @JsonCreator constructor( - @JsonProperty("messages") - // Apply converter to the element so that if one of the message fails to deserialize, it will - // be a null value instead of failing the whole list. - @JsonDeserialize(contentConverter = RetrieveMessageConverter::class) - val messages: List, +@Serializable +data class RetrieveMessageResponse( + val messages: List, ) { - class Message( + @Serializable + data class Message( val hash: String, - val timestamp: Long?, - val data: ByteArray, - ) -} -internal class RetrieveMessageConverter : StdConverter() { - override fun convert(value: JsonNode?): RetrieveMessageResponse.Message? { - value ?: return null + // Some messages use "t" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") + private val t1: Instant? = null, - val hash = value.get("hash")?.asText()?.takeIf { it.isNotEmpty() } ?: return null - val timestamp = value.get("t")?.asLong()?.takeIf { it > 0 } - val data = runCatching { - Base64.decode(value.get("data")?.asText().orEmpty(), Base64.DEFAULT) - }.getOrNull() ?: return null + // Some messages use "timestamp" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("timestamp") + private val t2: Instant? = null, - return RetrieveMessageResponse.Message(hash, timestamp, data) + @SerialName("data") + val dataB64: String? = null, + ) { + val data: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + Base64.decode(dataB64, Base64.DEFAULT) + } + + val timestamp: Instant get() = requireNotNull(t1 ?: t2) { + "Message timestamp is missing" + } } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index d01e013bd8..37545d464b 100644 --- a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -29,6 +29,7 @@ import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.snode.SwarmAuth import org.session.libsignal.utilities.AccountId +import java.time.Instant interface ConfigFactoryProtocol { val configUpdateNotifications: Flow @@ -103,7 +104,7 @@ class ConfigMessage( data class ConfigPushResult( val hashes: List, - val timestamp: Long + val timestamp: Instant ) enum class UserConfigType(val namespace: Int) { diff --git a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java index bdd9964d5e..536b1e13a4 100644 --- a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java +++ b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java @@ -8,6 +8,11 @@ import org.session.libsignal.utilities.Log; +/** + * @deprecated The logic here has been moved to SessionProtocol, this class only exists + * so the old persisted message queue can be read. It will be removed in a future release. + */ +@Deprecated(forRemoval = true) public class PushTransportDetails { private static final String TAG = PushTransportDetails.class.getSimpleName(); diff --git a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 9d0d4a6241..c855d52a94 100644 --- a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -20,11 +20,6 @@ interface LokiAPIDatabaseProtocol { fun clearLastMessageHashes(publicKey: String) fun clearLastMessageHashesByNamespaces(vararg namespaces: Int) fun clearAllLastMessageHashes() - fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? - fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) - fun clearReceivedMessageHashValues(publicKey: String) - fun clearReceivedMessageHashValues() - fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) fun getAuthToken(server: String): String? fun setAuthToken(server: String, newValue: String?) fun getLastMessageServerID(room: String, server: String): Long? diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index 93d5841385..a3444934fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -71,7 +71,7 @@ import network.loki.messenger.databinding.MediaViewPageBinding import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved -import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.Address @@ -136,6 +136,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), @Inject lateinit var recipientRepository: RecipientRepository + @Inject + lateinit var messageSender: MessageSender + override val applyDefaultWindowInsets: Boolean get() = false @@ -552,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), nowWithOffset ) ) - send(message, conversationAddress!!) + messageSender.send(message, conversationAddress!!) } @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index 22d9fe9310..567dc491c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -28,12 +28,14 @@ public class TypingStatusSender { private final Map selfTypingTimers; private final ThreadDatabase threadDatabase; private final RecipientRepository recipientRepository; + private final MessageSender messageSender; @Inject - public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository) { + public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository, MessageSender messageSender) { this.threadDatabase = threadDatabase; this.recipientRepository = recipientRepository; this.selfTypingTimers = new HashMap<>(); + this.messageSender = messageSender; } public synchronized void onTypingStarted(long threadId) { @@ -94,7 +96,7 @@ private void sendTyping(long threadId, boolean typingStarted) { } else { typingIndicator = new TypingIndicator(TypingIndicator.Kind.STOPPED); } - MessageSender.send(typingIndicator, recipient.getAddress()); + messageSender.send(typingIndicator, recipient.getAddress()); } private class StartRunnable implements Runnable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 4fae3a59fb..d2eed7ef9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -81,6 +82,7 @@ class ConfigToDatabaseSync @Inject constructor( private val groupMemberDatabase: GroupMemberDatabase, private val communityDatabase: CommunityDatabase, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val clock: SnodeClock, private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, @@ -195,7 +197,7 @@ class ConfigToDatabaseSync @Inject constructor( private fun deleteGroupData(address: Address.Group) { lokiAPIDatabase.clearLastMessageHashes(address.accountId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(address.accountId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(address.accountId.hexString) } private fun onLegacyGroupAdded( diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index d7300a1e4d..2fa7ba3202 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -250,7 +250,7 @@ class ConfigUploader @Inject constructor( ), auth ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ).let(::listOf).toConfigPushResult() } @@ -284,7 +284,7 @@ class ConfigUploader @Inject constructor( val pendingConfig = configs.groupKeys.pendingConfig() if (pendingConfig != null) { for (hash in hashes) { - configs.groupKeys.loadKey(pendingConfig, hash, timestamp) + configs.groupKeys.loadKey(pendingConfig, hash, timestamp.toEpochMilli()) } } } @@ -329,7 +329,7 @@ class ConfigUploader @Inject constructor( ), auth, ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index ac4257cd92..fe43d12f71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -29,6 +29,7 @@ class DisappearingMessages @Inject constructor( private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, private val clock: SnodeClock, + private val messageSender: MessageSender, ) { fun set(address: Address, mode: ExpiryMode, isGroup: Boolean) { storage.setExpirationConfiguration(address, mode) @@ -45,7 +46,7 @@ class DisappearingMessages @Inject constructor( } messageExpirationManager.insertExpirationTimerMessage(message) - MessageSender.send(message, address) + messageSender.send(message, address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index ba0903f2b0..9d7059d622 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -264,6 +264,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var openGroupManager: OpenGroupManager @Inject lateinit var attachmentDatabase: AttachmentDatabase @Inject lateinit var clock: SnodeClock + @Inject lateinit var messageSender: MessageSender + @Inject lateinit var resendMessageUtilities: ResendMessageUtilities @Inject @ManagerScope lateinit var scope: CoroutineScope @@ -1790,7 +1792,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(reactionMessage, recipient.address) + messageSender.send(reactionMessage, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) @@ -1843,7 +1845,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) } @@ -2137,7 +2139,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ), false) waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } // Send a typing stopped message typingStatusSender.onTypingStopped(viewModel.threadId) @@ -2215,7 +2217,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address, quote, linkPreview) + messageSender.send(message, recipient.address, quote, linkPreview) }.onFailure { withContext(Dispatchers.Main){ when (it) { @@ -2558,7 +2560,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey, @@ -2576,7 +2578,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey @@ -2731,7 +2733,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } private fun endActionMode() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 2f47041204..1e3fb60b8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.OpenGroupInvitation @@ -12,8 +12,12 @@ import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.toGroupString import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import javax.inject.Inject -object ResendMessageUtilities { +class ResendMessageUtilities @Inject constructor( + private val messageSender: MessageSender, + private val storage: StorageProtocol, +) { suspend fun resend(accountId: String?, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient = messageRecord.recipient.address @@ -51,14 +55,14 @@ object ResendMessageUtilities { message.addSignalAttachments(messageRecord.slideDeck.asAttachments()) } val sentTimestamp = message.sentTimestamp - val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() + val sender = storage.getUserPublicKey() if (sentTimestamp != null && sender != null) { if (isResync) { - MessagingModuleConfiguration.shared.storage.markAsResyncing(messageRecord.messageId) - MessageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) + storage.markAsResyncing(messageRecord.messageId) + messageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) } else { - MessagingModuleConfiguration.shared.storage.markAsSending(messageRecord.messageId) - MessageSender.send(message, recipient) + storage.markAsSending(messageRecord.messageId) + messageSender.send(message, recipient) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 6a16e68a7d..eadffadb3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -49,6 +49,8 @@ class LokiAPIDatabase(context: Context, helper: Provider) : = "CREATE TABLE $legacyLastMessageHashValueTable2 ($snode TEXT, $publicKey TEXT, $lastMessageHashValue TEXT, PRIMARY KEY ($snode, $publicKey));" // Received message hash values private const val legacyReceivedMessageHashValuesTable3 = "received_message_hash_values_table_3" + + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase") private const val receivedMessageHashValuesTable = "session_received_message_hash_values_table" private const val receivedMessageHashValues = "received_message_hash_values" private const val receivedMessageHashNamespace = "received_message_namespace" @@ -128,6 +130,7 @@ class LokiAPIDatabase(context: Context, helper: Provider) : const val INSERT_LAST_HASH_DATA = "INSERT OR IGNORE INTO $lastMessageHashValueTable2($snode, $publicKey, $lastMessageHashValue) SELECT $snode, $publicKey, $lastMessageHashValue FROM $legacyLastMessageHashValueTable2;" const val DROP_LEGACY_LAST_HASH = "DROP TABLE $legacyLastMessageHashValueTable2;" + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase, keeping here just for migration purpose") const val UPDATE_RECEIVED_INCLUDE_NAMESPACE_COMMAND = """ CREATE TABLE IF NOT EXISTS $receivedMessageHashValuesTable( $publicKey STRING, $receivedMessageHashValues TEXT, $receivedMessageHashNamespace INTEGER DEFAULT 0, PRIMARY KEY ($publicKey, $receivedMessageHashNamespace) @@ -311,43 +314,6 @@ class LokiAPIDatabase(context: Context, helper: Provider) : database.delete(lastMessageHashValueTable2, null, null) } - override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? { - val database = readableDatabase - val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" - return database.get(receivedMessageHashValuesTable, query, arrayOf( publicKey, namespace.toString() )) { cursor -> - val receivedMessageHashValuesAsString = cursor.getString(cursor.getColumnIndexOrThrow(Companion.receivedMessageHashValues)) - receivedMessageHashValuesAsString.split("-").toSet() - } - } - - override fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) { - val database = writableDatabase - val receivedMessageHashValuesAsString = newValue.joinToString("-") - val row = wrap(mapOf( - Companion.publicKey to publicKey, - Companion.receivedMessageHashValues to receivedMessageHashValuesAsString, - Companion.receivedMessageHashNamespace to namespace.toString() - )) - val query = "${Companion.publicKey} = ? AND $receivedMessageHashNamespace = ?" - database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() )) - } - - override fun clearReceivedMessageHashValues(publicKey: String) { - writableDatabase - .delete(receivedMessageHashValuesTable, "${Companion.publicKey} = ?", arrayOf(publicKey)) - } - - override fun clearReceivedMessageHashValues() { - val database = writableDatabase - database.delete(receivedMessageHashValuesTable, null, null) - } - - override fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) { - // Note that we don't use SQL parameter as the given namespaces are integer anyway so there's little chance of SQL injection - writableDatabase - .delete(receivedMessageHashValuesTable, "$receivedMessageHashNamespace IN (${namespaces.joinToString(",")})", null) - } - override fun getAuthToken(server: String): String? { val database = readableDatabase return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt new file mode 100644 index 0000000000..9436481548 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.collection.LruCache +import androidx.sqlite.db.SupportSQLiteDatabase +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * This database table keeps track of which message hashes we've already received for + * particular senders (swarm public keys) and namespaces. This is used to prevent + * processing the same message multiple times. + * + * To use this class, call [checkOrUpdateDuplicateState] to atomically check if a message hash + * has already been seen, and if not, add it to the database. + */ +@Singleton +class ReceivedMessageHashDatabase @Inject constructor( + @ApplicationContext context: Context, + databaseHelper: Provider, + private val json: Json, +) : Database(context, databaseHelper) { + + private data class CacheKey(val publicKey: String, val namespace: Int, val hash: String) + + private val cache = LruCache(1024) + + fun removeAllByNamespaces(vararg namespaces: Int) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE namespace IN (SELECT value FROM json_each(?)) + """, json.encodeToString(namespaces)) + } + + fun removeAllByPublicKey(publicKey: String) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE swarm_pub_key = ? + """, publicKey) + } + + fun removeAll() { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL("DELETE FROM received_messages WHERE 1") + } + + /** + * Checks if the given [hash] is already present in the database for the given + * [swarmPublicKey] and [namespace]. If not, adds it to the database. + * + * This implementation is atomic. + * + * @return true if the hash was already in the db + */ + fun checkOrUpdateDuplicateState( + swarmPublicKey: String, + namespace: Int, + hash: String + ): Boolean { + val key = CacheKey(swarmPublicKey, namespace, hash) + synchronized(cache) { + if (cache[key] != null) { + return true + } + } + + //language=roomsql + return writableDatabase.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + stmt.bindString(1, swarmPublicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.executeUpdateDelete() == 0 + }.also { + synchronized(cache) { + cache.put(key, Unit) + } + } + } + + companion object { + fun createAndMigrateTable(db: SupportSQLiteDatabase) { + //language=roomsql + db.execSQL(""" + CREATE TABLE IF NOT EXISTS received_messages( + swarm_pub_key TEXT NOT NULL, + namespace INTEGER NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (swarm_pub_key, namespace, hash) + ) WITHOUT ROWID; + """) + + //language=roomsql + db.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + + //language=roomsql + db.query(""" + SELECT public_key, received_message_hash_values, received_message_namespace + FROM session_received_message_hash_values_table + """, arrayOf()).use { cursor -> + while (cursor.moveToNext()) { + val publicKey = cursor.getString(0) + val hashValuesString = cursor.getString(1) + val namespace = cursor.getInt(2) + + val hashValues = hashValuesString.splitToSequence('-') + + for (hash in hashValues) { + stmt.bindString(1, publicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.execute() + stmt.clearBindings() + } + } + } + } + + //language=roomsql + db.execSQL("DROP TABLE session_received_message_hash_values_table") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index ec0cdbf912..8c85d55df5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.notifications.MarkReadProcessor; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.util.SharedConfigUtilsKt; @@ -239,6 +240,7 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { private final Lazy<@NonNull MessageNotifier> messageNotifier; private final Lazy<@NonNull MmsDatabase> mmsDatabase; private final Lazy<@NonNull SmsDatabase> smsDatabase; + private final Lazy<@NonNull MarkReadProcessor> markReadProcessor; @Inject public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context context, @@ -249,6 +251,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context Lazy<@NonNull MessageNotifier> messageNotifier, Lazy<@NonNull MmsDatabase> mmsDatabase, Lazy<@NonNull SmsDatabase> smsDatabase, + Lazy<@NonNull MarkReadProcessor> markReadProcessor, TextSecurePreferences prefs, Json json) { super(context, databaseHelper); @@ -258,6 +261,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context this.messageNotifier = messageNotifier; this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; + this.markReadProcessor = markReadProcessor; this.json = json; this.prefs = prefs; @@ -768,7 +772,7 @@ public boolean isRead(long threadId) { public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force, boolean updateNotifications) { if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); - MarkReadReceiver.process(context, messages); + markReadProcessor.get().process(messages); if(updateNotifications) messageNotifier.get().updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 0ab54cbdca..3816b3920f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.PushRegistrationDatabase; import org.thoughtcrime.securesms.database.ReactionDatabase; +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientSettingsDatabase; import org.thoughtcrime.securesms.database.SearchDatabase; @@ -102,9 +103,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV53 = 74; private static final int lokiV54 = 75; private static final int lokiV55 = 76; + private static final int lokiV56 = 77; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV55; + private static final int DATABASE_VERSION = lokiV56; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -266,6 +268,8 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); + + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); } @Override @@ -604,6 +608,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); } + if (oldVersion < lokiV56) { + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 8b421e3644..d41121879c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -390,7 +390,7 @@ class ConfigFactory @Inject constructor( // We need to persist the data to the database to save timestamp after the push val userAccountId = requiresCurrentUserAccountId() for ((variant, data, timestamp) in dump) { - configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp) + configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp.toEpochMilli()) } } @@ -412,11 +412,11 @@ class ConfigFactory @Inject constructor( if (pendingConfig != null) { for (hash in hashes) { configs.groupKeys.loadKey( - pendingConfig, - hash, - timestamp, - configs.groupInfo.pointer, - configs.groupMembers.pointer + message = pendingConfig, + hash = hash, + timestampMs = timestamp.toEpochMilli(), + infoPtr = configs.groupInfo.pointer, + membersPtr = configs.groupMembers.pointer ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index 141cb8233b..476b70925d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -42,6 +42,7 @@ class GroupLeavingWorker @AssistedInject constructor( private val groupScope: GroupScope, private val tokenFetcher: TokenFetcher, private val pushRegistryV2: PushRegistryV2, + private val messageSender: MessageSender, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val groupId = requireNotNull(inputData.getString(KEY_GROUP_ID)) { @@ -98,7 +99,7 @@ class GroupLeavingWorker @AssistedInject constructor( val statusChannel = Channel>() // Always send a "XXX left" message to the group if we can - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) @@ -110,7 +111,7 @@ class GroupLeavingWorker @AssistedInject constructor( // If we are not the only admin, send a left message for other admin to handle the member removal // We'll have to wait for this message to be sent before going ahead to delete the group - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index c5e3acf8dc..89efd74846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.configs.ConfigUploader import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -84,10 +85,13 @@ class GroupManagerV2Impl @Inject constructor( private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val configUploader: ConfigUploader, private val scope: GroupScope, private val groupPollerManager: GroupPollerManager, private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val inviteContactJobFactory: InviteContactsJob.Factory, ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -201,7 +205,7 @@ class GroupManagerV2Impl @Inject constructor( // Invite members JobQueue.shared.add( - InviteContactsJob( + inviteContactJobFactory.create( groupSessionId = groupId.hexString, memberSessionIds = members.map { it.hexString }.toTypedArray() ) @@ -321,9 +325,9 @@ class GroupManagerV2Impl @Inject constructor( // Send the invitation message to the new members JobQueue.shared.add( - InviteContactsJob( - group.hexString, - newMembers.map { it.hexString }.toTypedArray() + inviteContactJobFactory.create( + groupSessionId = group.hexString, + memberSessionIds = newMembers.map { it.hexString }.toTypedArray() ) ) } @@ -355,7 +359,7 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(updatedMessage, group) - MessageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) + messageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) } override suspend fun removeMembers( @@ -394,7 +398,7 @@ class GroupManagerV2Impl @Inject constructor( updateMessage ).apply { sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) + messageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) storage.insertGroupInfoChange(message, groupAccountId) } @@ -524,7 +528,7 @@ class GroupManagerV2Impl @Inject constructor( val promotionDeferred = members.associateWith { member -> async { // The promotion message shouldn't be persisted to avoid being retried automatically - MessageSender.sendNonDurably( + messageSender.sendNonDurably( message = promoteMessage, address = Address.fromSerialized(member.hexString), isSyncMessage = false, @@ -555,7 +559,7 @@ class GroupManagerV2Impl @Inject constructor( if (!isRepromote) { - MessageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) } } } @@ -659,7 +663,7 @@ class GroupManagerV2Impl @Inject constructor( val responseMessage = GroupUpdated(responseData.build(), profile = storage.getUserProfile()) // this will fail the first couple of times :) runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( responseMessage, Destination.ClosedGroup(group.groupAccountId), isSyncMessage = false @@ -883,7 +887,7 @@ class GroupManagerV2Impl @Inject constructor( // Clear all polling states lokiAPIDatabase.clearLastMessageHashes(groupId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(groupId.hexString) SessionMetaProtocol.clearReceivedMessages() configFactory.deleteGroupConfigs(groupId) @@ -926,7 +930,7 @@ class GroupManagerV2Impl @Inject constructor( } storage.insertGroupInfoChange(message, groupId) - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun setDescription(groupId: AccountId, newDescription: String): Unit = @@ -1008,7 +1012,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun handleDeleteMemberContent( @@ -1141,7 +1145,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupId.hexString)) + messageSender.send(message, Address.fromSerialized(groupId.hexString)) storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupExpirationUpdated::class.java) storage.insertGroupInfoChange(message, groupId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index f6c44c9a9f..76954f62e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -6,6 +6,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay @@ -19,15 +20,13 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.RetrieveMessageResponse +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.getGroup @@ -36,6 +35,7 @@ import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.getRootCause import java.time.Instant @@ -51,7 +51,9 @@ class GroupPoller @AssistedInject constructor( private val clock: SnodeClock, private val appVisibilityManager: AppVisibilityManager, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -254,8 +256,8 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.REVOKED_GROUP_MESSAGES(), maxSize = null, ), - RetrieveMessageResponse::class.java - ).messages.filterNotNull() + RetrieveMessageResponse.serializer() + ).messages } if (configHashesToExtends.isNotEmpty() && adminKey != null) { @@ -290,7 +292,7 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.GROUP_MESSAGES(), maxSize = null, ), - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } @@ -313,8 +315,8 @@ class GroupPoller @AssistedInject constructor( namespace = ns, maxSize = null, ), - responseType = RetrieveMessageResponse::class.java - ).messages.filterNotNull() + responseType = RetrieveMessageResponse.serializer() + ).messages } } @@ -323,7 +325,7 @@ class GroupPoller @AssistedInject constructor( // must be processed first. pollingTasks += "polling and handling group config keys and messages" to async { val result = runCatching { - val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() } + val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.awaitAll() handleGroupConfigMessages(keysMessage, infoMessage, membersMessage) saveLastMessageHash(snode, keysMessage, Namespace.GROUP_KEYS()) saveLastMessageHash(snode, infoMessage, Namespace.GROUP_INFO()) @@ -334,7 +336,16 @@ class GroupPoller @AssistedInject constructor( } val regularMessages = groupMessageRetrieval.await() - handleMessages(regularMessages, snode) + handleMessages(regularMessages.messages) + + regularMessages.messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = groupId.hexString, + newValue = newest.hash, + namespace = Namespace.GROUP_MESSAGES() + ) + } } // Revoke message must be handled regardless, and at the end @@ -381,7 +392,7 @@ class GroupPoller @AssistedInject constructor( if (badResponse != null) { Log.e(TAG, "Group polling failed due to a server error", badResponse) - pollState.swarmNodes -= currentSnode!! + pollState.swarmNodes -= currentSnode } } } @@ -397,7 +408,7 @@ class GroupPoller @AssistedInject constructor( } private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { - return ConfigMessage(hash, data, timestamp ?: clock.currentTimeMills()) + return ConfigMessage(hash, data, timestamp.toEpochMilli()) } private fun saveLastMessageHash( @@ -443,38 +454,47 @@ class GroupPoller @AssistedInject constructor( ) } - private fun handleMessages(body: RawResponse, snode: Snode) { - val messages = configFactoryProtocol.withGroupConfigs(groupId) { - SnodeAPI.parseRawMessagesResponse( - rawResponse = body, - snode = snode, - publicKey = groupId.hexString, - decrypt = { data -> - val (decrypted, sender) = it.groupKeys.decrypt(data) ?: return@parseRawMessagesResponse null - decrypted to AccountId(sender) - }, - namespace = Namespace.GROUP_MESSAGES(), - ) + private fun handleMessages(messages: List) { + if (messages.isEmpty()) { + return } - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters( - envelope.toByteArray(), - serverHash = serverHash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } + val start = System.currentTimeMillis() + val threadAddress = Address.Group(groupId) + + receivedMessageProcessor.startProcessing("GroupPoller($groupId)") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = Namespace.GROUP_MESSAGES(), + hash = message.hash + )) { + Log.v(TAG, "Skipping duplicated group message ${message.hash} for group $groupId") + continue + } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } + try { + val (msg, proto) = messageParser.parseGroupMessage( + data = message.data, + serverHash = message.hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) - if (messages.isNotEmpty()) { - Log.d(TAG, "Received and handled ${messages.size} group messages") + receivedMessageProcessor.processSwarmMessage( + threadAddress = threadAddress, + message = msg, + proto = proto, + context = ctx, + ) + } catch (e: Exception) { + Log.e(TAG, "Error handling group message", e) + } + } } + + Log.d(TAG, "Handled ${messages.size} group messages for $groupId in ${System.currentTimeMillis() - start}ms") } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index c118a0983a..09242a4967 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -62,6 +62,7 @@ class RemoveGroupMemberHandler @Inject constructor( private val storage: StorageProtocol, private val groupScope: GroupScope, @ManagerScope scope: CoroutineScope, + private val messageSender: MessageSender, ) : OnAppStartupComponent { init { scope.launch { @@ -220,7 +221,7 @@ class RemoveGroupMemberHandler @Inject constructor( ): SnodeMessage { val timestamp = clock.currentTimeMills() - return MessageSender.buildWrappedMessageToSnode( + return messageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), message = GroupUpdated( SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 807fff6de5..6d8c645b3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -57,7 +57,8 @@ class MediaOverviewViewModel @AssistedInject constructor( private val threadDatabase: ThreadDatabase, private val mediaDatabase: MediaDatabase, private val dateUtils: DateUtils, - private val recipientRepository: RecipientRepository, + recipientRepository: RecipientRepository, + private val messageSender: MessageSender, ) : AndroidViewModel(application) { private val timeBuckets by lazy { FixedTimeBuckets() } @@ -293,7 +294,7 @@ class MediaOverviewViewModel @AssistedInject constructor( val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, address) + messageSender.send(message, address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 2d22a13532..62173b3390 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -25,17 +25,24 @@ import androidx.core.app.NotificationManagerCompat; +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.LinkedList; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + /** * Marks an Android Auto as read after the driver have listened to it */ +@AndroidEntryPoint public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String TAG = AndroidAutoHeardReceiver.class.getSimpleName(); @@ -43,6 +50,10 @@ public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String THREAD_IDS_EXTRA = "car_heard_thread_ids"; public static final String NOTIFICATION_ID_EXTRA = "car_notification_id"; + @Inject MarkReadProcessor markReadProcessor; + @Inject MessageNotifier messageNotifier; + @Inject ThreadDatabase threadDb; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) @@ -63,13 +74,13 @@ protected Void doInBackground(Void... params) { for (long threadId : threadIds) { Log.i(TAG, "Marking meassage as read: " + threadId); - List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); + List messageIds = threadDb.setRead(threadId, true); messageIdsCollection.addAll(messageIds); } - ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); - MarkReadReceiver.process(context, messageIdsCollection); + messageNotifier.updateNotification(context); + markReadProcessor.process(messageIdsCollection); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 26561eee2a..42b3d406dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -77,6 +77,12 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { @Inject MessageNotifier messageNotifier; + @Inject + MarkReadProcessor markReadProcessor; + + @Inject + MessageSender messageSender; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) @@ -107,7 +113,7 @@ protected Void doInBackground(Void... params) { VisibleMessage message = new VisibleMessage(); message.setText(responseText.toString()); message.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(message, address); + messageSender.send(message, address); ExpiryMode expiryMode = recipientRepository.getRecipientSync(address).getExpiryMode(); long expiresInMillis = expiryMode.getExpiryMillis(); long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; @@ -129,7 +135,7 @@ protected Void doInBackground(Void... params) { List messageIds = threadDatabase.setRead(replyThreadId, true); messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); + markReadProcessor.process(messageIds); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt new file mode 100644 index 0000000000..ba0795b630 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import javax.inject.Inject + +class MarkReadProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val mmsSmsDatabase: MmsSmsDatabase, + private val threadDb: ThreadDatabase, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, +) { + fun process( + markedReadMessages: List + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts( + markedReadMessages = markedReadMessages + ) + + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .asSequence() + .filter { it.expiryType == ExpiryType.AFTER_READ } + .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { + (messageContent is DisappearingMessageUpdate) + && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false + } + .forEach { + val db = if (it.expirationInfo.id.mms) { + DatabaseComponent.get(context).mmsDatabase() + } else { + DatabaseComponent.get(context).smsDatabase() + } + + db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) + } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> + GlobalScope.launch { + try { + shortenExpiryOfDisappearingAfterRead(hashToMessages) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) + } + } + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List + ): Map? { + val loki = DatabaseComponent.get(context).lokiMessageDatabase() + + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + hashToMessage: Map + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = snodeClock.currentTimeMills() + expiresIn, + auth = checkNotNull(storage.userAuth) { "No authorized user" }, + shorten = true + ) + } + } + + private val Recipient.shouldSendReadReceipt: Boolean + get() = when (data) { + is RecipientData.Contact -> approved && !blocked + is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked + else -> false + } + + private fun sendReadReceipts( + markedReadMessages: List + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { recipientRepository.getRecipientSync(it.address).shouldSendReadReceipt } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = snodeClock.currentTimeMills() } + .let { messageSender.send(it, address) } + } + } + + companion object { + private const val TAG = "MarkReadProcessor" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 1b8cb58d23..6b1cd80d4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -8,24 +8,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.sending_receiving.MessageSender.send -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeClock -import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled -import org.session.libsession.utilities.associateByNotNull -import org.session.libsession.utilities.isGroupOrCommunity -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType -import org.thoughtcrime.securesms.database.MarkedMessageInfo -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject @AndroidEntryPoint @@ -36,6 +20,7 @@ class MarkReadReceiver : BroadcastReceiver() { @Inject lateinit var clock: SnodeClock + override fun onReceive(context: Context, intent: Intent) { if (CLEAR_ACTION != intent.action) return val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return @@ -59,101 +44,5 @@ class MarkReadReceiver : BroadcastReceiver() { const val THREAD_IDS_EXTRA = "thread_ids" const val NOTIFICATION_ID_EXTRA = "notification_id" - @JvmStatic - fun process( - context: Context, - markedReadMessages: List - ) { - if (markedReadMessages.isEmpty()) return - - sendReadReceipts(context, markedReadMessages) - - val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - - val threadDb = DatabaseComponent.get(context).threadDatabase() - - // start disappear after read messages except TimerUpdates in groups. - markedReadMessages - .asSequence() - .filter { it.expiryType == ExpiryType.AFTER_READ } - .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { - (messageContent is DisappearingMessageUpdate) - && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false - } - .forEach { - val db = if (it.expirationInfo.id.mms) { - DatabaseComponent.get(context).mmsDatabase() - } else { - DatabaseComponent.get(context).smsDatabase() - } - - db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) - } - - hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> - GlobalScope.launch { - try { - shortenExpiryOfDisappearingAfterRead(hashToMessages) - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) - } - } - } - } - - private fun hashToDisappearAfterReadMessage( - context: Context, - markedReadMessages: List - ): Map? { - val loki = DatabaseComponent.get(context).lokiMessageDatabase() - - return markedReadMessages - .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } - .takeIf { it.isNotEmpty() } - } - - private fun shortenExpiryOfDisappearingAfterRead( - hashToMessage: Map - ) { - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - SnodeAPI.alterTtl( - messageHashes = hashes, - newExpiry = nowWithOffset + expiresIn, - auth = checkNotNull(shared.storage.userAuth) { "No authorized user" }, - shorten = true - ) - } - } - - private val Recipient.shouldSendReadReceipt: Boolean - get() = when (data) { - is RecipientData.Contact -> approved && !blocked - is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked - else -> false - } - - private fun sendReadReceipts( - context: Context, - markedReadMessages: List - ) { - if (!isReadReceiptsEnabled(context)) return - - val recipientRepository = MessagingModuleConfiguration.shared.recipientRepository - - markedReadMessages.map { it.syncMessageId } - .filter { recipientRepository.getRecipientSync(it.address)?.shouldSendReadReceipt == true } - .groupBy { it.address } - .forEach { (address, messages) -> - messages.map { it.timetamp } - .let(::ReadReceipt) - .apply { sentTimestamp = nowWithOffset } - .let { send(it, address) } - } - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 8c34519359..162c6cc8fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -10,31 +10,32 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.SessionEncrypt import okio.ByteString.Companion.decodeHex -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata -import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString import org.session.libsession.utilities.getGroup -import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler import org.thoughtcrime.securesms.home.HomeActivity import java.security.SecureRandom @@ -47,7 +48,10 @@ class PushReceiver @Inject constructor( private val configFactory: ConfigFactory, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, private val json: Json, - private val batchJobFactory: BatchMessageReceiveJob.Factory, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + @param:ManagerScope private val scope: CoroutineScope, ) { /** @@ -70,7 +74,7 @@ class PushReceiver @Inject constructor( private fun addMessageReceiveJob(pushData: PushData?) { try { val namespace = pushData?.metadata?.namespace - val params = when { + when { namespace == Namespace.GROUP_MESSAGES() || namespace == Namespace.REVOKED_GROUP_MESSAGES() || namespace == Namespace.GROUP_INFO() || @@ -91,48 +95,63 @@ class PushReceiver @Inject constructor( return } - if (namespace == Namespace.GROUP_MESSAGES()) { - val envelope = checkNotNull(tryDecryptGroupEnvelope(groupId, pushData.data)) { - "Unable to decrypt closed group message" + when (namespace) { + Namespace.GROUP_MESSAGES() -> { + if (!receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = namespace, + hash = pushData.metadata.msg_hash + )) { + receivedMessageProcessor.startProcessing("GroupPushReceive($groupId)") { ctx -> + val (msg, proto) = messageParser.parseGroupMessage( + data = pushData.data, + serverHash = pushData.metadata.msg_hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = Address.Group(groupId), + message = msg, + proto = proto, + context = ctx, + ) + } + } } - MessageReceiveParameters( - data = envelope.toByteArray(), - serverHash = pushData.metadata.msg_hash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } else if (namespace == Namespace.REVOKED_GROUP_MESSAGES()) { - GlobalScope.launch { - groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + Namespace.REVOKED_GROUP_MESSAGES() -> { + scope.launch { + groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + } } - null - } else { - val hash = requireNotNull(pushData.metadata.msg_hash) { - "Received a closed group config push notification without a message hash" - } + else -> { + val hash = requireNotNull(pushData.metadata.msg_hash) { + "Received a closed group config push notification without a message hash" + } + + // If we receive group config messages from notification, try to merge + // them directly + val configMessage = listOf( + ConfigMessage( + hash = hash, + data = pushData.data, + timestamp = pushData.metadata.timestampSeconds + ) + ) - // If we receive group config messages from notification, try to merge - // them directly - val configMessage = listOf( - ConfigMessage( - hash = hash, - data = pushData.data, - timestamp = pushData.metadata.timestampSeconds + configFactory.mergeGroupConfigMessages( + groupId = groupId, + keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } + .orEmpty(), + members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } + .orEmpty(), + info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } + .orEmpty(), ) - ) - - configFactory.mergeGroupConfigMessages( - groupId = groupId, - keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } - .orEmpty(), - members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } - .orEmpty(), - info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } - .orEmpty(), - ) - - null + } } } @@ -146,11 +165,29 @@ class PushReceiver @Inject constructor( return } - val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() - MessageReceiveParameters( - data = envelopeAsData, - serverHash = pushData.metadata?.msg_hash + val isDuplicated = pushData.metadata?.msg_hash != null && receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = pushData.metadata.account, + namespace = Namespace.DEFAULT(), + hash = pushData.metadata.msg_hash ) + + if (!isDuplicated) { + receivedMessageProcessor.startProcessing("PushReceiver") { ctx -> + val (message, proto) = messageParser.parse1o1Message( + data = pushData.data, + serverHash = pushData.metadata?.msg_hash, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } + } } else -> { @@ -159,33 +196,12 @@ class PushReceiver @Inject constructor( } } - if (params != null) { - JobQueue.shared.add(batchJobFactory.create( - messages = listOf(params), - fromCommunity = null - )) - } } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } } - private fun tryDecryptGroupEnvelope(groupId: AccountId, data: ByteArray): Envelope? { - val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { - it.groupKeys.decrypt( - data - ) - }) { - "Failed to decrypt group message" - } - - Log.d(TAG, "Successfully decrypted group message from $sender") - return Envelope.parseFrom(envelopBytes) - .toBuilder() - .setSource(sender) - .build() - } private fun sendGenericNotification() { // no need to do anything if notification permissions are not granted @@ -265,7 +281,7 @@ class PushReceiver @Inject constructor( return key } - data class PushData( + class PushData( val data: ByteArray?, val metadata: PushNotificationMetadata? ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 216eadaae0..9d10f23a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -76,6 +76,10 @@ public class RemoteReplyReceiver extends BroadcastReceiver { SnodeClock clock; @Inject RecipientRepository recipientRepository; + @Inject + MarkReadProcessor markReadProcessor; + @Inject + MessageSender messageSender; @SuppressLint("StaticFieldLeak") @Override @@ -110,7 +114,7 @@ protected Void doInBackground(Void... params) { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, address, Collections.emptyList(), null, null, expiresInMillis, 0); try { message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, true), true)); - MessageSender.send(message, address); + messageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); } @@ -119,7 +123,7 @@ protected Void doInBackground(Void... params) { case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, address, expiresInMillis, expireStartedAt); message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), true), false)); - MessageSender.send(message, address); + messageSender.send(message, address); break; } default: @@ -129,7 +133,7 @@ protected Void doInBackground(Void... params) { List messageIds = threadDatabase.setRead(threadId, true); messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); + markReadProcessor.process(messageIds); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index 5602ccb9d7..6c9f34166a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -9,6 +9,7 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @@ -18,7 +19,8 @@ class CreateAccountManager @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, private val versionDataFetcher: VersionDataFetcher, - private val configFactory: ConfigFactoryProtocol + private val configFactory: ConfigFactoryProtocol, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -27,7 +29,7 @@ class CreateAccountManager @Inject constructor( // This is here to resolve a case where the app restarts before a user completes onboarding // which can result in an invalid database state database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() + receivedMessageHashDatabase.removeAll() val keyPairGenerationResult = KeyPairUtilities.generate() val seed = keyPairGenerationResult.seed diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt index 489742a9a2..c7dd30f462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -11,6 +11,7 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject @@ -20,7 +21,8 @@ import javax.inject.Singleton class LoadAccountManager @Inject constructor( @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, private val prefs: TextSecurePreferences, - private val versionDataFetcher: VersionDataFetcher + private val versionDataFetcher: VersionDataFetcher, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -37,7 +39,7 @@ class LoadAccountManager @Inject constructor( // This is here to resolve a case where the app restarts before a user completes onboarding // which can result in an invalid database state database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() + receivedMessageHashDatabase.removeAll() // RestoreActivity handles seed this way val keyPairGenerationResult = KeyPairUtilities.generate(seed) diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 56b8d0bef7..c6a0fa7cb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -144,6 +144,7 @@ class DefaultConversationRepository @Inject constructor( private val recipientDatabase: RecipientSettingsDatabase, private val recipientRepository: RecipientRepository, @param:ManagerScope private val scope: CoroutineScope, + private val messageSender: MessageSender, ) : ConversationRepository { override val conversationListAddressesFlow = configFactory @@ -285,7 +286,7 @@ class DefaultConversationRepository @Inject constructor( false ) - MessageSender.send(message, contact) + messageSender.send(message, contact) } } @@ -410,12 +411,12 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + userAddress?.let { messageSender.send(unsendRequest, it) } } // send an UnsendRequest to recipient's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -428,7 +429,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // send an UnsendRequest to group's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -467,7 +468,7 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + userAddress?.let { messageSender.send(unsendRequest, it) } } } } @@ -551,7 +552,7 @@ class DefaultConversationRepository @Inject constructor( } withContext(Dispatchers.Default) { - MessageSender.send(message = MessageRequestResponse(true), address = recipient) + messageSender.send(message = MessageRequestResponse(true), address = recipient) // add a control message for our user storage.insertMessageRequestResponseFromYou(threadDb.getOrCreateThreadIdFor(recipient)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 5a864b0f80..168e46276d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -74,6 +74,7 @@ class CallManager @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, audioManager: AudioManagerCompat, private val storage: StorageProtocol, + private val messageSender: MessageSender, ): PeerConnection.Observer, SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { @@ -340,7 +341,7 @@ class CallManager @Inject constructor( .also { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( it, currentRecipient, isSyncMessage = currentRecipient.isLocalNumber @@ -491,7 +492,7 @@ class CallManager @Inject constructor( Log.i("Loki", "Posting new answer") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, recipient, isSyncMessage = recipient.isLocalNumber @@ -545,7 +546,7 @@ class CallManager @Inject constructor( val userAddress = storage.getUserPublicKey() ?: throw NullPointerException("No user public key") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true @@ -553,7 +554,7 @@ class CallManager @Inject constructor( } runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.answer( answer.description, callId @@ -609,7 +610,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending pre-offer") try { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.preOffer( callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -619,7 +620,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending offer") postViewModelState(CallViewModel.State.CALL_OFFER_OUTGOING) - MessageSender.sendNonDurably(CallMessage.offer( + messageSender.sendNonDurably(CallMessage.offer( offer.description, callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) @@ -639,7 +640,7 @@ class CallManager @Inject constructor( stateProcessor.processEvent(Event.DeclineCall) { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), Address.fromSerialized(userAddress), isSyncMessage = true @@ -648,7 +649,7 @@ class CallManager @Inject constructor( } scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -680,7 +681,7 @@ class CallManager @Inject constructor( scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -919,7 +920,7 @@ class CallManager @Inject constructor( connection.setLocalDescription(offer) scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.offer(offer.description, callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca93ac2782..7b13062bc6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ kotlinVersion = "2.2.20" kryoVersion = "5.6.2" kspVersion = "2.3.0" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.9-2-g8c03d1e" +libsessionUtilAndroidVersion = "1.0.9-22-gc15b19c" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" navVersion = "2.9.5" From 361d7ffd42366fe2accf6c98335e0fab55a9cdb3 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:12:43 +1100 Subject: [PATCH 127/219] Fix contacts being created incorrectly on group threads --- .../messaging/sending_receiving/VisibleMessageHandler.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt index 06f9213bf0..a5e1332f5e 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -172,8 +172,11 @@ class VisibleMessageHandler @Inject constructor( runThreadUpdate = runThreadUpdate ) ?: return null - // If we have previously "hidden" the sender, we should flip the flag back to visible - if (senderAddress is Address.Standard && senderAddress.address != ctx.currentUserPublicKey) { + // If we have previously "hidden" the sender, we should flip the flag back to visible, + // and this should only be done only for 1:1 messages + if (senderAddress is Address.Standard && + senderAddress.address != ctx.currentUserPublicKey && + threadAddress is Address.Standard) { val existingContact = configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } From a872dade816d462473fb96b50225a6e6d2316928 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:12:51 +1100 Subject: [PATCH 128/219] Fix contacts being created incorrectly on group threads (for legacy receiver) --- .../messaging/sending_receiving/ReceivedMessageHandler.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index bf891abf98..624693ca98 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -414,8 +414,10 @@ class ReceivedMessageHandler @Inject constructor( runThreadUpdate = runThreadUpdate ) ?: return null - // If we have previously "hidden" the sender, we should flip the flag back to visible - if (senderAddress is Address.Standard && senderAddress.address != userPublicKey) { + // If we have previously "hidden" the sender, we should flip the flag back to visible, + // and this should only be done only for 1:1 messages + if (senderAddress is Address.Standard && senderAddress.address != userPublicKey + && context.threadAddress is Address.Standard) { val existingContact = configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } From bbf47a23689c3f7f1e4c4c90d65d8fdbeb39092b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 10 Nov 2025 14:31:14 +1100 Subject: [PATCH 129/219] Properly handling back and "forward" actions in the Pro confirmation screen --- .../settings/ConversationSettingsNavHost.kt | 2 ++ .../securesms/debugmenu/DebugMenuNavHost.kt | 2 +- .../StartConversationSheet.kt | 2 +- .../prosettings/PlanConfirmationScreen.kt | 8 +++-- .../prosettings/ProSettingsNavHost.kt | 30 +++++++++++++++++++ .../prosettings/ProSettingsViewModel.kt | 24 ++++++++------- .../thoughtcrime/securesms/ui/UINavigator.kt | 8 +++++ 7 files changed, 61 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt index 5a540d1d0c..8af681b86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/settings/ConversationSettingsNavHost.kt @@ -135,6 +135,8 @@ fun ConversationSettingsNavHost( is NavigationAction.ReturnResult -> { returnResult(action.code, action.value) } + + else -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt index 0eb22ac784..491418be66 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuNavHost.kt @@ -66,7 +66,7 @@ fun DebugMenuNavHost( navController.context.startActivity(action.intent) } - is NavigationAction.ReturnResult -> {} + else -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index 9ce71dfba7..c34a8a0a7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -130,7 +130,7 @@ fun StartConversationNavHost( navController.context.startActivity(action.intent) } - is NavigationAction.ReturnResult -> {} + else -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt index 13c34c79ad..0124c43a47 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/PlanConfirmationScreen.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.preferences.prosettings +import androidx.activity.compose.BackHandler import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -37,7 +38,6 @@ import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NETWORK_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.PRO_KEY import org.session.libsession.utilities.recipients.ProStatus -import org.thoughtcrime.securesms.preferences.prosettings.ProSettingsViewModel.Commands.GoToProSettings import org.thoughtcrime.securesms.pro.SubscriptionDetails import org.thoughtcrime.securesms.pro.SubscriptionState import org.thoughtcrime.securesms.pro.SubscriptionType @@ -79,6 +79,10 @@ fun PlanConfirmation( sendCommand: (ProSettingsViewModel.Commands) -> Unit, onBack: () -> Unit, ) { + BackHandler { + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) + } + Scaffold( topBar = {}, contentWindowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal), @@ -163,7 +167,7 @@ fun PlanConfirmation( .widthIn(max = LocalDimensions.current.maxContentWidth), text = buttonLabel, onClick = { - sendCommand(GoToProSettings) + sendCommand(ProSettingsViewModel.Commands.OnPostPlanConfirmation) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt index cc7b20e95e..42dcdce86f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsNavHost.kt @@ -48,6 +48,10 @@ sealed interface ProSettingsDestination: Parcelable { data object RefundSubscription: ProSettingsDestination } +enum class ProNavHostCustomActions { + ON_POST_PLAN_CONFIRMATION +} + @Serializable object ProSettingsGraph @SuppressLint("RestrictedApi") @@ -86,6 +90,32 @@ fun ProSettingsNavHost( navController.context.startActivity(action.intent) } + is NavigationAction.PerformCustomAction -> { + when(action.data as? ProNavHostCustomActions){ + // handle the custom case of dealing with the post "choose plan confirmation"screen + ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION -> { + // we get here where we either hit back or hit the "ok" button on the plan confirmation screen + // if we are in a sheet we need to close it + if (inSheet) { + onBack() + } // otherwise we should clear the stack and head back to the pro settings home screen + else { + // try to navigate "back" home is possible + val wentBack = navController.popBackStack(route = Home, inclusive = false) + + if (!wentBack) { + // Fallback: if Home wasn't in the back stack + navController.navigate(Home){ + popUpTo(Home){ inclusive = false } + } + } + } + } + + else -> {} + } + } + is NavigationAction.ReturnResult -> {} } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index d77cd8da41..474b55c286 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -303,16 +303,11 @@ class ProSettingsViewModel @AssistedInject constructor( } } - Commands.GoToProSettings -> { - // navigate back to home and pop all other screens off the stack - navigateTo( - destination = ProSettingsDestination.Home, - navOptions = { - popUpTo(ProSettingsDestination.Home){ - inclusive = true - } - } - ) + Commands.OnPostPlanConfirmation -> { + // send a custom action to deal with "post plan confirmation" + viewModelScope.launch { + navigator.sendCustomAction(ProNavHostCustomActions.ON_POST_PLAN_CONFIRMATION) + } } Commands.OpenSubscriptionPage -> { @@ -360,6 +355,13 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GetProPlan -> { + // TEMP !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + viewModelScope.launch { + navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) + } + return + + val currentSubscription = _proSettingsUIState.value.subscriptionState.type val selectedPlan = getSelectedPlan() ?: return @@ -736,7 +738,7 @@ class ProSettingsViewModel @AssistedInject constructor( data class GoToChoosePlan(val inSheet: Boolean): Commands object GoToRefund: Commands object GoToCancel: Commands - object GoToProSettings: Commands + object OnPostPlanConfirmation: Commands object OpenSubscriptionPage: Commands diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt index 6fdcf5ce90..8aa4c05bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/UINavigator.kt @@ -38,6 +38,10 @@ class UINavigator () { _navigationActions.send(NavigationAction.NavigateToIntent(intent)) } + suspend fun sendCustomAction(data: Any){ + _navigationActions.send(NavigationAction.PerformCustomAction(data)) + } + suspend fun returnResult(code: String, value: Boolean) { _navigationActions.send(NavigationAction.ReturnResult(code, value)) } @@ -59,4 +63,8 @@ sealed interface NavigationAction { val code: String, val value: Boolean ) : NavigationAction + + data class PerformCustomAction( + val data: Any + ) : NavigationAction } \ No newline at end of file From 25bad28c9864f462a935537e1415e1186540fc41 Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 10 Nov 2025 14:33:37 +1100 Subject: [PATCH 130/219] Removed temp code --- .../preferences/prosettings/ProSettingsViewModel.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt index 474b55c286..36308c8f17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/prosettings/ProSettingsViewModel.kt @@ -355,13 +355,6 @@ class ProSettingsViewModel @AssistedInject constructor( } Commands.GetProPlan -> { - // TEMP !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - viewModelScope.launch { - navigator.navigate(destination = ProSettingsDestination.PlanConfirmation) - } - return - - val currentSubscription = _proSettingsUIState.value.subscriptionState.type val selectedPlan = getSelectedPlan() ?: return From 019f6becb0bb47514936c13941248daa24be9677 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:52:25 +1100 Subject: [PATCH 131/219] Blinded request message parsing and processing --- .../sending_receiving/MessageParser.kt | 38 +++++++++++ .../ReceivedMessageProcessor.kt | 66 +++++++++++++++++-- .../pollers/OpenGroupPoller.kt | 20 +++++- 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt index 26daf05fc4..4cf5defd89 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -1,5 +1,6 @@ package org.session.libsession.messaging.sending_receiving +import network.loki.messenger.libsession_util.SessionEncrypt import network.loki.messenger.libsession_util.protocol.DecodedEnvelope import network.loki.messenger.libsession_util.protocol.SessionProtocol import org.session.libsession.database.StorageProtocol @@ -15,11 +16,13 @@ import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -237,4 +240,39 @@ class MessageParser @Inject constructor( message.openGroupServerMessageID = msg.id } } + + fun parseCommunityDirectMessage( + msg: OpenGroupApi.DirectMessage, + communityServerPubKeyHex: String, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair { + val (senderId, plaintext) = SessionEncrypt.decryptForBlindedRecipient( + ciphertext = Base64.decode(msg.message), + myEd25519Privkey = currentUserEd25519PrivKey, + openGroupPubkey = Hex.fromStringCondensed(communityServerPubKeyHex), + senderBlindedId = Hex.fromStringCondensed(msg.sender), + recipientBlindId = Hex.fromStringCondensed(msg.recipient), + ) + + val decoded = SessionProtocol.decodeForCommunity( + payload = plaintext.data, + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + val sender = Address.Standard(AccountId(senderId)) + + return parseMessage( + contentPlaintext = decoded.contentPlainText.data, + relaxSignatureCheck = true, + checkForBlockStatus = false, + isForGroup = false, + currentUserId = currentUserId, + sender = sender.accountId, + messageTimestampMs = (msg.postedAt * 1000), + currentUserBlindedIDs = currentUserBlindedIDs, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt index bbb3d408ea..9dc0ce1eb5 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -14,6 +14,7 @@ import okio.withLock import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.userAuth import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate @@ -29,6 +30,7 @@ import org.session.libsession.messaging.sending_receiving.notifications.MessageN import org.session.libsession.messaging.utilities.WebRtcUtils import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID import org.session.libsession.utilities.SSKEnvironment @@ -76,6 +78,15 @@ class ReceivedMessageProcessor @Inject constructor( ) { private val threadMutexes = ConcurrentHashMap() + private inline fun withThreadLock( + threadAddress: Address.Conversable, + block: () -> T + ) { + threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + block() + } + } + /** * Start a message processing session, ensuring that thread updates and notifications are handled @@ -119,7 +130,7 @@ class ReceivedMessageProcessor @Inject constructor( threadAddress: Address.Conversable, message: Message, proto: SignalServiceProtos.Content, - ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + ) = withThreadLock(threadAddress) { // The logic to check if the message should be discarded due to being from a hidden contact. if (threadAddress is Address.Standard && message.sentTimestamp != null && @@ -130,7 +141,7 @@ class ReceivedMessageProcessor @Inject constructor( ) ) { log { "Dropping message from hidden contact ${threadAddress.debugString}" } - return@withLock + return@withThreadLock } // Get or create thread ID, if we aren't allowed to create it, and it doesn't exist, drop the message @@ -142,7 +153,7 @@ class ReceivedMessageProcessor @Inject constructor( .also { id -> if (id == -1L) { log { "Dropping message for non-existing thread ${threadAddress.debugString}" } - return@withLock + return@withThreadLock } else { context.threadIDs[threadAddress] = id } @@ -201,23 +212,64 @@ class ReceivedMessageProcessor @Inject constructor( fun processCommunityInboxMessage( context: MessageProcessingContext, + communityServerUrl: String, + communityServerPubKeyHex: String, message: OpenGroupApi.DirectMessage ) { - //TODO("Waiting for the implementation from libsession_util") + val (message, proto) = messageParser.parseCommunityDirectMessage( + msg = message, + currentUserId = context.currentUserId, + currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByServer(communityServerUrl), + communityServerPubKeyHex = communityServerPubKeyHex, + ) + + val threadAddress = message.senderOrSync.toAddress() as Address.Conversable + + withThreadLock(threadAddress) { + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = message, + proto = proto + ) + } } fun processCommunityOutboxMessage( context: MessageProcessingContext, - message: OpenGroupApi.DirectMessage + communityServerUrl: String, + communityServerPubKeyHex: String, + msg: OpenGroupApi.DirectMessage ) { - //TODO("Waiting for the implementation from libsession_util") + val (message, proto) = messageParser.parseCommunityDirectMessage( + msg = msg, + currentUserId = context.currentUserId, + currentUserEd25519PrivKey = context.currentUserEd25519KeyPair.secretKey.data, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByServer(communityServerUrl), + communityServerPubKeyHex = communityServerPubKeyHex, + ) + + val threadAddress = Address.CommunityBlindedId( + serverUrl = communityServerUrl, + blindedId = Address.Blinded(AccountId(msg.recipient)) + ) + + withThreadLock(threadAddress) { + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = message, + proto = proto + ) + } } fun processCommunityMessage( context: MessageProcessingContext, threadAddress: Address.Community, message: OpenGroupApi.Message, - ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + ) = withThreadLock(threadAddress) { var messageId = messageParser.parseCommunityMessage( msg = message, currentUserId = context.currentUserId, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 9ad9213197..e83caa4d04 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -332,6 +332,12 @@ class OpenGroupPoller @AssistedInject constructor( if (messages.isEmpty()) return val sorted = messages.sortedBy { it.postedAt } + val serverPubKeyHex = storage.getOpenGroupPublicKey(server) + ?: run { + Log.e(TAG, "No community server public key cannot process inbox messages") + return + } + receivedMessageProcessor.startProcessing("CommunityInbox") { ctx -> for (apiMessage in sorted) { try { @@ -340,6 +346,8 @@ class OpenGroupPoller @AssistedInject constructor( receivedMessageProcessor.processCommunityInboxMessage( context = ctx, message = apiMessage, + communityServerUrl = server, + communityServerPubKeyHex = serverPubKeyHex, ) } catch (e: Exception) { @@ -358,6 +366,12 @@ class OpenGroupPoller @AssistedInject constructor( if (messages.isEmpty()) return val sorted = messages.sortedBy { it.postedAt } + val serverPubKeyHex = storage.getOpenGroupPublicKey(server) + ?: run { + Log.e(TAG, "No community server public key cannot process inbox messages") + return + } + receivedMessageProcessor.startProcessing("CommunityOutbox") { ctx -> for (apiMessage in sorted) { try { @@ -365,11 +379,13 @@ class OpenGroupPoller @AssistedInject constructor( receivedMessageProcessor.processCommunityOutboxMessage( context = ctx, - message = apiMessage, + msg = apiMessage, + communityServerUrl = server, + communityServerPubKeyHex = serverPubKeyHex, ) } catch (e: Exception) { - Log.e(TAG, "Error processing inbox message", e) + Log.e(TAG, "Error processing outbox message", e) } } } From e131b1d2210c7f069154d8592d704ff861094c9a Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 10 Nov 2025 15:53:12 +1100 Subject: [PATCH 132/219] Clean up --- .../StartConversationSheet.kt | 204 +++++++++--------- .../securesms/pro/ProStatusManager.kt | 1 - .../pro/subscription/SubscriptionManager.kt | 1 - 3 files changed, 101 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt index c34a8a0a7a..e19cb6de74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/StartConversationSheet.kt @@ -111,133 +111,131 @@ fun StartConversationNavHost( accountId: String, onClose: () -> Unit ){ - SharedTransitionLayout { - val navController = rememberNavController() - val navigator: UINavigator = - remember { UINavigator() } - - ObserveAsEvents(flow = navigator.navigationActions) { action -> - when (action) { - is NavigationAction.Navigate -> navController.navigate( - action.destination - ) { - action.navOptions(this) - } - - NavigationAction.NavigateUp -> navController.navigateUp() + val navController = rememberNavController() + val navigator: UINavigator = + remember { UINavigator() } + + ObserveAsEvents(flow = navigator.navigationActions) { action -> + when (action) { + is NavigationAction.Navigate -> navController.navigate( + action.destination + ) { + action.navOptions(this) + } - is NavigationAction.NavigateToIntent -> { - navController.context.startActivity(action.intent) - } + NavigationAction.NavigateUp -> navController.navigateUp() - else -> {} + is NavigationAction.NavigateToIntent -> { + navController.context.startActivity(action.intent) } + + else -> {} } + } - val scope = rememberCoroutineScope() - val activity = LocalActivity.current - val context = LocalContext.current + val scope = rememberCoroutineScope() + val activity = LocalActivity.current + val context = LocalContext.current + + NavHost(navController = navController, startDestination = StartConversationDestination.Home) { + // Home + horizontalSlideComposable { + StartConversationScreen ( + accountId = accountId, + onClose = onClose, + navigateTo = { + scope.launch { navigator.navigate(it) } + } + ) + } - NavHost(navController = navController, startDestination = StartConversationDestination.Home) { - // Home - horizontalSlideComposable { - StartConversationScreen ( - accountId = accountId, - onClose = onClose, - navigateTo = { - scope.launch { navigator.navigate(it) } - } - ) - } + // New Message + horizontalSlideComposable { + val viewModel = hiltViewModel() + val uiState by viewModel.state.collectAsState(State()) + + val helpUrl = "https://getsession.org/account-ids" - // New Message - horizontalSlideComposable { - val viewModel = hiltViewModel() - val uiState by viewModel.state.collectAsState(State()) - - val helpUrl = "https://getsession.org/account-ids" - - LaunchedEffect(Unit) { - scope.launch { - viewModel.success.collect { - context.startActivity( - ConversationActivityV2.createIntent( - context, - address = it.address - ) + LaunchedEffect(Unit) { + scope.launch { + viewModel.success.collect { + context.startActivity( + ConversationActivityV2.createIntent( + context, + address = it.address ) + ) - onClose() - } + onClose() } } - - NewMessage( - uiState, - viewModel.qrErrors, - viewModel, - onBack = { scope.launch { navigator.navigateUp() } }, - onClose = onClose, - onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } - ) - if (uiState.showUrlDialog) { - OpenURLAlertDialog( - url = helpUrl, - onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } - ) - } } - // Create Group - horizontalSlideComposable { - CreateGroupScreen( - onNavigateToConversationScreen = { address -> - activity?.startActivity( - ConversationActivityV2.createIntent(activity, address) - ) - }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose, - fromLegacyGroupId = null, + NewMessage( + uiState, + viewModel.qrErrors, + viewModel, + onBack = { scope.launch { navigator.navigateUp() } }, + onClose = onClose, + onHelp = { viewModel.onCommand(NewMessageViewModel.Commands.ShowUrlDialog) } + ) + if (uiState.showUrlDialog) { + OpenURLAlertDialog( + url = helpUrl, + onDismissRequest = { viewModel.onCommand(NewMessageViewModel.Commands.DismissUrlDialog) } ) } + } + + // Create Group + horizontalSlideComposable { + CreateGroupScreen( + onNavigateToConversationScreen = { address -> + activity?.startActivity( + ConversationActivityV2.createIntent(activity, address) + ) + }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose, + fromLegacyGroupId = null, + ) + } - // Join Community - horizontalSlideComposable { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsState() - - LaunchedEffect(Unit){ - scope.launch { - viewModel.uiEvents.collect { - when(it){ - is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { - onClose() - activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) - } + // Join Community + horizontalSlideComposable { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit){ + scope.launch { + viewModel.uiEvents.collect { + when(it){ + is JoinCommunityViewModel.UiEvent.NavigateToConversation -> { + onClose() + activity?.startActivity(ConversationActivityV2.createIntent(activity, it.address)) } } } } - - JoinCommunityScreen( - state = state, - sendCommand = { viewModel.onCommand(it) }, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) } - // Invite Friend - horizontalSlideComposable { - InviteFriend( - accountId = accountId, - onBack = { scope.launch { navigator.navigateUp() }}, - onClose = onClose - ) - } + JoinCommunityScreen( + state = state, + sendCommand = { viewModel.onCommand(it) }, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) + } + // Invite Friend + horizontalSlideComposable { + InviteFriend( + accountId = accountId, + onBack = { scope.launch { navigator.navigateUp() }}, + onClose = onClose + ) } + } } 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 f291bb6543..62b87f16c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/ProStatusManager.kt @@ -275,7 +275,6 @@ class ProStatusManager @Inject constructor( //todo PRO call AddProPaymentRequest in libsession // we should `AddProPaymentRequest` with exponential backoff - // the call might fail until the back end has had time to acknowledge the payment /** * Here are the errors from the back end that we will need to be aware of diff --git a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt index c977771607..6c3d9ddd74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pro/subscription/SubscriptionManager.kt @@ -69,7 +69,6 @@ abstract class SubscriptionManager( */ protected fun onPurchaseSuccessful(){ // we need to tie our purchase with the back end - scope.launch { try { proStatusManager.appProPaymentToBackend() From 5e0cc7e73faeb0a19a18d1a9bfa4a708aeffab6b Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 10 Nov 2025 16:16:00 +1100 Subject: [PATCH 133/219] Updated CTAs --- .../securesms/ui/ProComponents.kt | 8 ++++---- .../res/drawable/cta_hero_animated_bg.webp | Bin 473102 -> 471126 bytes .../res/drawable/cta_hero_char_limit.webp | Bin 577484 -> 533698 bytes .../res/drawable/cta_hero_generic_bg.webp | Bin 536814 -> 532798 bytes app/src/main/res/drawable/cta_hero_pins.webp | Bin 535376 -> 543970 bytes 5 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt index 5642474178..3bbb92351e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/ProComponents.kt @@ -580,8 +580,8 @@ fun GenericProCTA( .format() .toString(), features = listOf( - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -613,7 +613,7 @@ fun LongMessageProCTA( .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -646,7 +646,7 @@ fun AnimatedProfilePicProCTA( .toString(), features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListAnimatedDisplayPicture)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { @@ -700,7 +700,7 @@ fun PinProCTA( text = title, features = listOf( CTAFeature.Icon(stringResource(R.string.proFeatureListPinnedConversations)), - CTAFeature.Icon(stringResource(R.string.proFeatureListLargerGroups)), + CTAFeature.Icon(stringResource(R.string.proFeatureListLongerMessages)), CTAFeature.RainbowIcon(stringResource(R.string.proFeatureListLoadsMore)), ), onCancel = { diff --git a/app/src/main/res/drawable/cta_hero_animated_bg.webp b/app/src/main/res/drawable/cta_hero_animated_bg.webp index 9d2ee88e15b2448df19613fc8eb94e3c1c7bd0dc..d6571612a7973f0a0d3cd5c6b3af113225cc13fb 100644 GIT binary patch literal 471126 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