From 6ad806afb737e42907fda1e19cd3acf0d07770c7 Mon Sep 17 00:00:00 2001 From: SessionHero01 <180888785+SessionHero01@users.noreply.github.com> Date: Wed, 5 Feb 2025 14:49:27 +1100 Subject: [PATCH] [SES-3251] - Legacy group migration - Part I (#916) --- .../securesms/ApplicationContext.java | 8 +- .../securesms/configs/ConfigToDatabaseSync.kt | 3 +- .../conversation/v2/ConversationActivityV2.kt | 70 ++++---- .../conversation/v2/ConversationViewModel.kt | 70 +++++--- .../securesms/debugmenu/DebugMenu.kt | 158 ++++++++++++++++-- .../securesms/debugmenu/DebugMenuViewModel.kt | 27 ++- .../securesms/dependencies/AppModule.kt | 2 - .../dependencies/SessionUtilModule.kt | 18 ++ .../securesms/groups/ClosedGroupManager.kt | 2 +- .../notifications/BackgroundPollWorker.kt | 5 +- .../thoughtcrime/securesms/util/DateUtils.kt | 12 +- .../messaging/MessagingModuleConfiguration.kt | 4 +- .../groups/LegacyGroupDeprecationManager.kt | 74 ++++++++ .../MessageSenderClosedGroupHandler.kt | 2 +- .../ReceivedMessageHandler.kt | 4 +- .../pollers/LegacyClosedGroupPollerV2.kt | 32 +++- .../NonTranslatableStringConstants.kt | 1 + .../utilities/TextSecurePreferences.kt | 27 +++ 18 files changed, 416 insertions(+), 103 deletions(-) create mode 100644 libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index a3a395db66..6a95a2da30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -170,6 +170,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO @Inject Lazy messageNotifierLazy; @Inject LokiAPIDatabase apiDB; @Inject EmojiSearchDatabase emojiSearchDb; + @Inject LegacyClosedGroupPollerV2 legacyClosedGroupPollerV2; public volatile boolean isAppVisible; public String KEYGUARD_LOCK_TAG = NonTranslatableStringConstants.APP_NAME + ":KeyguardLock"; @@ -257,7 +258,8 @@ public class ApplicationContext extends Application implements DefaultLifecycleO tokenFetcher, groupManagerV2, snodeClock, - textSecurePreferences + textSecurePreferences, + legacyClosedGroupPollerV2 ); callMessageProcessor = new CallMessageProcessor(this, textSecurePreferences, ProcessLifecycleOwner.get().getLifecycle(), storage); Log.i(TAG, "onCreate()"); @@ -343,7 +345,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.stopIfNeeded(); } pollerFactory.stopAll(); - LegacyClosedGroupPollerV2.getShared().stopAll(); + legacyClosedGroupPollerV2.stopAll(); versionDataFetcher.stopTimedVersionCheck(); } @@ -455,7 +457,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO poller.startIfNeeded(); } pollerFactory.startAll(); - LegacyClosedGroupPollerV2.getShared().start(); + legacyClosedGroupPollerV2.start(); } public void retrieveUserProfile() { 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 1ee93b7794..fec7b330a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -71,6 +71,7 @@ class ConfigToDatabaseSync @Inject constructor( private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, private val mmsSmsDatabase: MmsSmsDatabase, + private val legacyClosedGroupPollerV2: LegacyClosedGroupPollerV2, ) { init { if (!preferences.migratedToGroupV2Config) { @@ -369,7 +370,7 @@ class ConfigToDatabaseSync @Inject constructor( // Don't create config group here, it's from a config update // Start polling - LegacyClosedGroupPollerV2.shared.startPolling(group.accountId) + legacyClosedGroupPollerV2.startPolling(group.accountId) } if (messageTimestamp != null) { 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 8917c4b7e4..6c55ef04b6 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 @@ -20,7 +20,7 @@ import android.os.Handler import android.os.Looper import android.provider.MediaStore import android.text.Spannable -import android.text.SpannableString +import android.text.SpannableStringBuilder import android.text.TextUtils import android.text.style.ImageSpan import android.util.Pair @@ -86,19 +86,16 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel -import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized import org.session.libsession.utilities.GroupUtil import org.session.libsession.utilities.MediaTypes +import org.session.libsession.utilities.NonTranslatableStringConstants import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.CONVERSATION_NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.DATE_TIME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.GROUP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.NAME_KEY -import org.session.libsession.utilities.StringSubstitutionConstants.URL_KEY import org.session.libsession.utilities.Stub import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.concurrent.SimpleTask @@ -833,41 +830,36 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } private fun setUpLegacyGroupBanner() { - val shouldDisplayBanner = viewModel.recipient?.isLegacyGroupRecipient ?: return - - binding.outdatedGroupBanner.isVisible = shouldDisplayBanner - if (!shouldDisplayBanner) return - - val url = "https://getsession.org/blog/session-groups-v2" - - with(binding) { - // Create a SpannableString with text - val text = SpannableString( - Phrase.from(this@ConversationActivityV2, R.string.groupLegacyBanner) - //TODO groupsv2, date - .put(DATE_KEY, "") - .format() - ) - - // we need to add the inline icon - val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external) - val imageSize = toPx(10, resources) - val imagePaddingTop = toPx(4, resources) - drawable?.setBounds(0, 0, imageSize, imageSize) - drawable?.setTint(getColorFromAttr(R.attr.message_sent_text_color)) - - // Create an ImageSpan with the drawable - val imageSpan = PaddedImageSpan(drawable!!, ImageSpan.ALIGN_BASELINE, imagePaddingTop) - - // Append the image to the text - val spannable = SpannableString(text) - spannable.setSpan(imageSpan, text.length - 1, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - - outdatedGroupBanner.text = spannable + lifecycleScope.launch { + viewModel.legacyGroupBanner + .collectLatest { banner -> + if (banner == null) { + binding.outdatedGroupBanner.isVisible = false + binding.outdatedGroupBanner.text = null + } else { + binding.outdatedGroupBanner.isVisible = true + binding.outdatedGroupBanner.text = SpannableStringBuilder(banner) + .apply { + // we need to add the inline icon + val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external)!! + val imageSize = toPx(10, resources) + val imagePaddingTop = toPx(4, resources) + drawable.setBounds(0, 0, imageSize, imageSize) + drawable.setTint(getColorFromAttr(R.attr.message_sent_text_color)) + + setSpan( + PaddedImageSpan(drawable, ImageSpan.ALIGN_BASELINE, imagePaddingTop), + length - 1, + length, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } - outdatedGroupBanner.setOnClickListener { - showOpenUrlDialog(url) - } + binding.outdatedGroupBanner.setOnClickListener { + showOpenUrlDialog(NonTranslatableStringConstants.GROUP_UPDATE_URL) + } + } + } } } 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 5a4044a090..3695f67316 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.goterl.lazysodium.utils.KeyPair +import com.squareup.phrase.Phrase import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext @@ -16,16 +17,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.consumeEach -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf 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 @@ -40,7 +38,7 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.fromSerialized -import org.session.libsession.utilities.ConfigUpdateNotification +import org.session.libsession.utilities.StringSubstitutionConstants.DATE_KEY import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.recipients.MessageType @@ -60,9 +58,12 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository +import org.thoughtcrime.securesms.util.DateUtils +import java.time.ZoneId import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) @@ -80,6 +81,7 @@ class ConversationViewModel( private val textSecurePreferences: TextSecurePreferences, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, + private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -195,6 +197,28 @@ class ConversationViewModel( // allow reactions if the open group is null (normal conversations) or the open group's capabilities include reactions get() = (openGroup == null || OpenGroupApi.Capability.REACTIONS.name.lowercase() in serverCapabilities) + val legacyGroupBanner: StateFlow = combine( + legacyGroupDeprecationManager.deprecationState, + legacyGroupDeprecationManager.deprecationTime, + isAdmin + ) { state, time, admin -> + when { + recipient?.isLegacyGroupRecipient != true -> null + state == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED -> { + Phrase.from(application, if (admin) R.string.legacyGroupAfterDeprecationAdmin else R.string.legacyGroupAfterDeprecationMember) + .format() + } + else -> Phrase.from(application, if (admin) R.string.legacyGroupBeforeDeprecationAdmin else R.string.legacyGroupBeforeDeprecationMember) + .put(DATE_KEY, + time.withZoneSameInstant(ZoneId.systemDefault()) + .toLocalDate() + .format(DateUtils.getShortDateFormatter()) + ) + .format() + } + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + private val attachmentDownloadHandler = AttachmentDownloadHandler( storage = storage, messageDataProvider = messageDataProvider, @@ -206,17 +230,18 @@ class ConversationViewModel( combine( repository.recipientUpdateFlow(threadId), _openGroup, - ) { a, b -> a to b } - .collect { (recipient, community) -> - _uiState.update { - it.copy( - shouldExit = recipient == null, - showInput = shouldShowInput(recipient, community), - enableInputMediaControls = shouldEnableInputMediaControls(recipient), - messageRequestState = buildMessageRequestState(recipient), - ) - } + legacyGroupDeprecationManager.deprecationState, + ::Triple + ).collect { (recipient, community, deprecationState) -> + _uiState.update { + it.copy( + shouldExit = recipient == null, + showInput = shouldShowInput(recipient, community, deprecationState), + enableInputMediaControls = shouldEnableInputMediaControls(recipient), + messageRequestState = buildMessageRequestState(recipient), + ) } + } } // Listen for changes in the open group's write access @@ -260,13 +285,18 @@ class ConversationViewModel( * For these situations we hide the input bar: * 1. The user has been kicked from a group(v2), OR * 2. The legacy group is inactive, OR - * 3. The community chat is read only + * 3. The legacy group is deprecated, OR + * 4. The community chat is read only */ - private fun shouldShowInput(recipient: Recipient?, community: OpenGroup?): Boolean { + private fun shouldShowInput(recipient: Recipient?, + community: OpenGroup?, + deprecationState: LegacyGroupDeprecationManager.DeprecationState + ): Boolean { return when { recipient?.isGroupV2Recipient == true -> !repository.isGroupReadOnly(recipient) recipient?.isLegacyGroupRecipient == true -> { - groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true + groupDb.getGroup(recipient.address.toGroupString()).orNull()?.isActive == true && + deprecationState != LegacyGroupDeprecationManager.DeprecationState.DEPRECATED } community != null -> community.canWrite else -> true @@ -1000,6 +1030,7 @@ class ConversationViewModel( private val textSecurePreferences: TextSecurePreferences, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, + private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -1017,6 +1048,7 @@ class ConversationViewModel( textSecurePreferences = textSecurePreferences, configFactory = configFactory, groupManagerV2 = groupManagerV2, + legacyGroupDeprecationManager = legacyGroupDeprecationManager, ) as T } } 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 162dee01e9..93ebeff500 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenu.kt @@ -2,27 +2,38 @@ package org.thoughtcrime.securesms.debugmenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope 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 import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchColors import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager @@ -33,6 +44,7 @@ import network.loki.messenger.R import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ChangeEnvironment import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.HideEnvironmentWarningDialog import org.thoughtcrime.securesms.debugmenu.DebugMenuViewModel.Commands.ShowEnvironmentWarningDialog +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.Cell import org.thoughtcrime.securesms.ui.DialogButtonModel @@ -45,6 +57,10 @@ 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.bold +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -55,6 +71,27 @@ fun DebugMenu( onClose: () -> Unit ) { val snackbarHostState = remember { SnackbarHostState() } + val showingDeprecationDatePicker = rememberDatePickerState() + + var showingDeprecatedTimePicker by remember { mutableStateOf(false) } + val deprecatedTimePickerState = rememberTimePickerState() + + val getPickedTime = { + val localDate = showingDeprecationDatePicker.selectedDateMillis?.let { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of("UTC")).toLocalDate() + } ?: uiState.forceDeprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalDate() + + val localTime = if (showingDeprecatedTimePicker) { + LocalTime.of( + deprecatedTimePickerState.hour, + deprecatedTimePickerState.minute + ) + } else { + uiState.forceDeprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalTime() + } + + ZonedDateTime.of(localDate, localTime, ZoneId.systemDefault()) + } Scaffold( modifier = modifier.fillMaxSize(), @@ -158,8 +195,105 @@ fun DebugMenu( } ) } + + // Group deprecation state + DebugCell("Legacy Group Deprecation Overrides") { + DropDown( + selectedText = uiState.forceDeprecationState.displayName, + values = uiState.availableDeprecationState.map { it.displayName }, + ) { selected -> + val override = LegacyGroupDeprecationManager.DeprecationState.entries + .firstOrNull { it.displayName == selected } + + sendCommand(DebugMenuViewModel.Commands.OverrideDeprecationState(override)) + } + + DebugRow(title = "Deprecated date", modifier = Modifier.clickable { + showingDeprecationDatePicker.selectedDateMillis = uiState.forceDeprecatedTime.withZoneSameLocal( + ZoneId.of("UTC")).toEpochSecond() * 1000L + }) { + Text(text = uiState.forceDeprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalDate().toString()) + } + + DebugRow(title = "Deprecated time", modifier = Modifier.clickable { + showingDeprecatedTimePicker = true + val time = uiState.forceDeprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalTime() + deprecatedTimePickerState.hour = time.hour + deprecatedTimePickerState.minute = time.minute + }) { + Text(text = uiState.forceDeprecatedTime.withZoneSameInstant(ZoneId.systemDefault()).toLocalTime().toString()) + } + } } } + + // Deprecation date picker + if (showingDeprecationDatePicker.selectedDateMillis != null) { + DatePickerDialog( + onDismissRequest = { + showingDeprecationDatePicker.selectedDateMillis = null + }, + confirmButton = { + TextButton(onClick = { + sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatedTime(getPickedTime())) + showingDeprecationDatePicker.selectedDateMillis = null + }) { + Text("Set", color = LocalColors.current.text) + } + }, + ) { + DatePicker(showingDeprecationDatePicker) + } + } + + if (showingDeprecatedTimePicker) { + AlertDialog( + onDismissRequest = { + showingDeprecatedTimePicker = false + }, + title = "Set Deprecated Time", + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.cancel), + onClick = { showingDeprecatedTimePicker = false } + ), + DialogButtonModel( + text = GetString(R.string.ok), + onClick = { + sendCommand(DebugMenuViewModel.Commands.OverrideDeprecatedTime(getPickedTime())) + showingDeprecatedTimePicker = false + } + ) + ) + ) { + TimePicker(deprecatedTimePickerState) + } + } + } +} + + +private val LegacyGroupDeprecationManager.DeprecationState?.displayName: String get() { + return this?.name ?: "No state override" +} + +@Composable +private fun DebugRow( + title: String, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier.heightIn(min = LocalDimensions.current.minItemButtonHeight), + verticalAlignment = Alignment.CenterVertically + ){ + Text( + text = title, + style = LocalType.current.base, + modifier = Modifier.weight(1f) + ) + + content() } } @@ -170,18 +304,12 @@ fun DebugSwitchRow( onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier ){ - Row( + DebugRow( + title = text, modifier = modifier .fillMaxWidth() .clickable { onCheckedChange(!checked) }, - verticalAlignment = Alignment.CenterVertically ){ - Text( - text = text, - style = LocalType.current.base, - modifier = Modifier.weight(1f) - ) - SessionSwitch( checked = checked, onCheckedChange = onCheckedChange @@ -213,7 +341,7 @@ fun SessionSwitch( fun ColumnScope.DebugCell( title: String, modifier: Modifier = Modifier, - content: @Composable () -> Unit + content: @Composable ColumnScope.() -> Unit ) { Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing)) @@ -221,15 +349,14 @@ fun ColumnScope.DebugCell( modifier = modifier ) { Column( - modifier = Modifier.padding(LocalDimensions.current.spacing) + modifier = Modifier.padding(LocalDimensions.current.spacing), + verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xsSpacing) ) { Text( text = title, style = LocalType.current.large.bold() ) - Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing)) - content() } } @@ -247,7 +374,10 @@ fun PreviewDebugMenu() { showEnvironmentWarningDialog = false, showEnvironmentLoadingDialog = false, hideMessageRequests = true, - hideNoteToSelf = false + hideNoteToSelf = false, + forceDeprecationState = null, + forceDeprecatedTime = ZonedDateTime.now(), + availableDeprecationState = emptyList() ), 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 506104b543..69d40d02ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/debugmenu/DebugMenuViewModel.kt @@ -15,13 +15,16 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import java.time.ZonedDateTime import javax.inject.Inject @HiltViewModel class DebugMenuViewModel @Inject constructor( private val application: Application, private val textSecurePreferences: TextSecurePreferences, - private val configFactory: ConfigFactory + private val configFactory: ConfigFactory, + private val deprecationManager: LegacyGroupDeprecationManager, ) : ViewModel() { private val TAG = "DebugMenu" @@ -33,7 +36,10 @@ class DebugMenuViewModel @Inject constructor( showEnvironmentWarningDialog = false, showEnvironmentLoadingDialog = false, hideMessageRequests = textSecurePreferences.hasHiddenMessageRequests(), - hideNoteToSelf = textSecurePreferences.hasHiddenNoteToSelf() + hideNoteToSelf = textSecurePreferences.hasHiddenNoteToSelf(), + forceDeprecationState = deprecationManager.deprecationStateOverride.value, + availableDeprecationState = listOf(null) + LegacyGroupDeprecationManager.DeprecationState.entries.toList(), + forceDeprecatedTime = deprecationManager.deprecationTime.value ) ) val uiState: StateFlow @@ -63,6 +69,16 @@ class DebugMenuViewModel @Inject constructor( } _uiState.value = _uiState.value.copy(hideNoteToSelf = command.hide) } + + is Commands.OverrideDeprecationState -> { + deprecationManager.overrideDeprecationState(command.state) + _uiState.value = _uiState.value.copy(forceDeprecationState = command.state) + } + + is Commands.OverrideDeprecatedTime -> { + deprecationManager.overrideDeprecatedTime(command.time) + _uiState.value = _uiState.value.copy(forceDeprecatedTime = command.time) + } } } @@ -112,7 +128,10 @@ class DebugMenuViewModel @Inject constructor( val showEnvironmentWarningDialog: Boolean, val showEnvironmentLoadingDialog: Boolean, val hideMessageRequests: Boolean, - val hideNoteToSelf: Boolean + val hideNoteToSelf: Boolean, + val forceDeprecationState: LegacyGroupDeprecationManager.DeprecationState?, + val availableDeprecationState: List, + val forceDeprecatedTime: ZonedDateTime ) sealed class Commands { @@ -121,5 +140,7 @@ class DebugMenuViewModel @Inject constructor( object HideEnvironmentWarningDialog : Commands() data class HideMessageRequest(val hide: Boolean) : Commands() data class HideNoteToSelf(val hide: Boolean) : Commands() + data class OverrideDeprecationState(val state: LegacyGroupDeprecationManager.DeprecationState?) : Commands() + data class OverrideDeprecatedTime(val time: ZonedDateTime) : Commands() } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt index 7bc30e2612..bfe21c0304 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppModule.kt @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.dependencies import android.content.Context -import android.widget.Toast import dagger.Binds import dagger.Module import dagger.Provides @@ -15,7 +14,6 @@ import org.session.libsession.utilities.AppTextSecurePreferences import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences -import org.session.libsession.utilities.Toaster import org.thoughtcrime.securesms.groups.GroupManagerV2Impl import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt index 9acdc8e172..1ead123b59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/SessionUtilModule.kt @@ -15,8 +15,11 @@ import kotlinx.coroutines.GlobalScope import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupScope +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.thoughtcrime.securesms.database.ConfigDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -64,4 +67,19 @@ object SessionUtilModule { @Provides @Singleton fun provideGroupScope() = GroupScope() + + @Provides + @Singleton + fun provideLegacyGroupPoller( + storage: StorageProtocol, + deprecationManager: LegacyGroupDeprecationManager + ): LegacyClosedGroupPollerV2 { + return LegacyClosedGroupPollerV2(storage, deprecationManager) + } + + @Provides + @Singleton + fun provideLegacyGroupDeprecationManager(prefs: TextSecurePreferences): LegacyGroupDeprecationManager { + return LegacyGroupDeprecationManager(prefs) + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt index cfc0e87c65..d0d1c65741 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ClosedGroupManager.kt @@ -25,7 +25,7 @@ object ClosedGroupManager { // Notify the PN server PushRegistryV1.unsubscribeGroup(closedGroupPublicKey = groupPublicKey, publicKey = userPublicKey) // Stop polling - LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.stopPolling(groupPublicKey) storage.cancelPendingMessageSendJobs(threadId) ApplicationContext.getInstance(context).messageNotifier.updateNotification(context) if (delete) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt index 25ff4c8a10..5cdc594d2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/BackgroundPollWorker.kt @@ -128,7 +128,10 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor // Closed groups if (requestTargets.contains(Targets.CLOSED_GROUPS)) { - val closedGroupPoller = LegacyClosedGroupPollerV2() // Intentionally don't use shared + val closedGroupPoller = LegacyClosedGroupPollerV2( + MessagingModuleConfiguration.shared.storage, + MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.deprecationManager + ) // Intentionally don't use shared val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() allGroupPublicKeys.iterator().forEach { closedGroupPoller.poll(it) } 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 36c64f7fbd..64a763e074 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.kt @@ -16,16 +16,10 @@ */ package org.thoughtcrime.securesms.util -import android.annotation.SuppressLint import android.content.Context -import android.os.Build import android.text.format.DateFormat -import androidx.compose.ui.text.capitalize -import org.session.libsignal.utilities.Log -import java.text.DateFormat.SHORT -import java.text.DateFormat.getTimeInstance -import java.text.ParseException import java.text.SimpleDateFormat +import java.time.format.DateTimeFormatter import java.util.Calendar import java.util.Date import java.util.Locale @@ -114,6 +108,10 @@ object DateUtils : android.text.format.DateUtils() { return SimpleDateFormat(dateFormatPattern, locale) } + fun getShortDateFormatter(): DateTimeFormatter { + return DateTimeFormatter.ofPattern("d MMM yyyy") + } + // Method to get the String for a relative day in a locale-aware fashion, including using the // auto-localised words for "today" and "yesterday" as appropriate. fun getRelativeDate( diff --git a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt index e74733cfde..da36e779f3 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/MessagingModuleConfiguration.kt @@ -7,6 +7,7 @@ import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupScope import org.session.libsession.messaging.notifications.TokenFetcher +import org.session.libsession.messaging.sending_receiving.pollers.LegacyClosedGroupPollerV2 import org.session.libsession.snode.OwnedSwarmAuth import org.session.libsession.snode.SnodeClock import org.session.libsession.utilities.ConfigFactoryProtocol @@ -26,7 +27,8 @@ class MessagingModuleConfiguration( val tokenFetcher: TokenFetcher, val groupManagerV2: GroupManagerV2, val clock: SnodeClock, - val preferences: TextSecurePreferences + val preferences: TextSecurePreferences, + val legacyClosedGroupPollerV2: LegacyClosedGroupPollerV2, ) { companion object { diff --git a/libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt b/libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt new file mode 100644 index 0000000000..9088b9ab6c --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/messaging/groups/LegacyGroupDeprecationManager.kt @@ -0,0 +1,74 @@ +package org.session.libsession.messaging.groups + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import org.session.libsession.utilities.TextSecurePreferences +import java.time.Duration +import java.time.ZoneId +import java.time.ZonedDateTime + +class LegacyGroupDeprecationManager(private val prefs: TextSecurePreferences) { + private val mutableDeprecationStateOverride = MutableStateFlow( + DeprecationState.entries.firstOrNull { it.name == prefs.deprecationStateOverride } + ) + + val deprecationStateOverride: StateFlow get() = mutableDeprecationStateOverride + + // The time all legacy groups will cease working. This value can be overridden by a debug + // facility. + private val defaultDeprecatedTime = ZonedDateTime.of(2025, 7, 1, 0, 0, 0, 0, ZoneId.of("UTC")) + + private val mutableDeprecatedTime = MutableStateFlow( + prefs.deprecatedTimeOverride ?: defaultDeprecatedTime + ) + + val deprecationTime: StateFlow get() = mutableDeprecatedTime + + @Suppress("OPT_IN_USAGE") + val deprecationState: StateFlow + get() = combine(mutableDeprecationStateOverride, mutableDeprecatedTime, ::Pair) + .flatMapLatest { (overriding, deadline) -> + if (overriding != null) { + flowOf(overriding) + } else { + flow { + val now = ZonedDateTime.now() + + if (now.isBefore(deadline)) { + emit(DeprecationState.DEPRECATING) + delay(Duration.between(now, deadline).toMillis()) + } + + emit(DeprecationState.DEPRECATED) + } + } + } + .stateIn( + scope = GlobalScope, + started = SharingStarted.Lazily, + initialValue = mutableDeprecationStateOverride.value ?: DeprecationState.DEPRECATING + ) + + fun overrideDeprecationState(deprecationState: DeprecationState?) { + mutableDeprecationStateOverride.value = deprecationState + prefs.deprecationStateOverride = deprecationState?.name + } + + fun overrideDeprecatedTime(deprecatedTime: ZonedDateTime?) { + mutableDeprecatedTime.value = deprecatedTime ?: defaultDeprecatedTime + prefs.deprecatedTimeOverride = deprecatedTime + } + + enum class DeprecationState { + DEPRECATING, + DEPRECATED + } +} diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt index fb9ffd1c92..d2c058b4a6 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSenderClosedGroupHandler.kt @@ -99,7 +99,7 @@ fun MessageSender.create( // Notify the PN server PushRegistryV1.register(device = device, publicKey = userPublicKey) // Start polling - LegacyClosedGroupPollerV2.shared.startPolling(groupPublicKey) + MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.startPolling(groupPublicKey) groupID } } diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index e46d9308df..bf23b4fc4e 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -871,7 +871,7 @@ private fun handleNewClosedGroup(sender: String, sentTimestamp: Long, groupPubli storage.insertIncomingInfoMessage(context, sender, groupID, SignalServiceGroup.Type.CREATION, name, members, admins, sentTimestamp) } // Start polling - LegacyClosedGroupPollerV2.shared.startPolling(groupPublicKey) + MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.startPolling(groupPublicKey) } private fun MessageReceiver.handleClosedGroupEncryptionKeyPair(message: ClosedGroupControlMessage) { @@ -1177,7 +1177,7 @@ fun MessageReceiver.disableLocalGroupAndUnsubscribe(groupPublicKey: String, grou // Notify the PN server PushRegistryV1.unsubscribeGroup(groupPublicKey, publicKey = userPublicKey) // Stop polling - LegacyClosedGroupPollerV2.shared.stopPolling(groupPublicKey) + MessagingModuleConfiguration.shared.legacyClosedGroupPollerV2.stopPolling(groupPublicKey) if (delete) { storage.getThreadId(Address.fromSerialized(groupID))?.let { threadId -> diff --git a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/LegacyClosedGroupPollerV2.kt b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/LegacyClosedGroupPollerV2.kt index aae2b6f3ab..ac21294cf5 100644 --- a/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/LegacyClosedGroupPollerV2.kt +++ b/libsession/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/LegacyClosedGroupPollerV2.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.GlobalScope import nl.komponents.kovenant.Promise import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map -import nl.komponents.kovenant.task -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue import org.session.libsession.messaging.jobs.MessageReceiveParameters @@ -25,7 +25,10 @@ import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit import kotlin.math.min -class LegacyClosedGroupPollerV2 { +class LegacyClosedGroupPollerV2( + private val storage: StorageProtocol, + val deprecationManager: LegacyGroupDeprecationManager, +) { private val executorService = Executors.newScheduledThreadPool(1) private var isPolling = mutableMapOf() private var futures = mutableMapOf>() @@ -34,19 +37,17 @@ class LegacyClosedGroupPollerV2 { return isPolling[groupPublicKey] ?: false } + private fun canPoll(): Boolean = deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATING + companion object { private val minPollInterval = 4 * 1000 private val maxPollInterval = 4 * 60 * 1000 - - @JvmStatic - val shared = LegacyClosedGroupPollerV2() } class InsufficientSnodesException() : Exception("No snodes left to poll.") class PollingCanceledException() : Exception("Polling canceled.") fun start() { - val storage = MessagingModuleConfiguration.shared.storage val allGroupPublicKeys = storage.getAllClosedGroupPublicKeys() allGroupPublicKeys.iterator().forEach { startPolling(it) } } @@ -77,10 +78,17 @@ class LegacyClosedGroupPollerV2 { } private fun pollRecursively(groupPublicKey: String) { - if (!isPolling(groupPublicKey)) { return } + if (!isPolling(groupPublicKey)) { + return + } + + if (!canPoll()) { + Log.d("Loki", "Unable to start polling due to being deprecated") + return + } + // Get the received date of the last message in the thread. If we don't have any messages yet, pick some // reasonable fake time interval to use instead. - val storage = MessagingModuleConfiguration.shared.storage val groupID = GroupUtil.doubleEncodeGroupID(groupPublicKey) val threadID = storage.getThreadId(groupID) if (threadID == null) { @@ -106,6 +114,12 @@ class LegacyClosedGroupPollerV2 { fun poll(groupPublicKey: String): Promise { if (!isPolling(groupPublicKey)) { return Promise.of(Unit) } + + if (!canPoll()) { + Log.d("Loki", "Unable to start polling due to being deprecated") + return Promise.of(Unit) + } + val promise = SnodeAPI.getSwarm(groupPublicKey).bind { swarm -> val snode = swarm.secureRandomOrNull() ?: throw InsufficientSnodesException() // Should be cryptographically secure if (!isPolling(groupPublicKey)) { throw PollingCanceledException() } diff --git a/libsession/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt b/libsession/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt index 31f893eccb..eab88e60a2 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/NonTranslatableStringConstants.kt @@ -6,5 +6,6 @@ object NonTranslatableStringConstants { const val SESSION_DOWNLOAD_URL = "https://getsession.org/download" const val GIF = "GIF" const val OXEN_FOUNDATION = "Oxen Foundation" + const val GROUP_UPDATE_URL = "https://getsession.org/blog/session-groups-v2" } diff --git a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt index bcc755fdc0..38499bda70 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/TextSecurePreferences.kt @@ -36,6 +36,7 @@ import org.session.libsession.utilities.TextSecurePreferences.Companion.SHOWN_CA import org.session.libsession.utilities.TextSecurePreferences.Companion._events import org.session.libsignal.utilities.Log import java.io.IOException +import java.time.ZonedDateTime import java.util.Arrays import java.util.Date import javax.inject.Inject @@ -196,6 +197,9 @@ interface TextSecurePreferences { fun getEnvironment(): Environment fun setEnvironment(value: Environment) + var deprecationStateOverride: String? + var deprecatedTimeOverride: ZonedDateTime? + var migratedToGroupV2Config: Boolean companion object { @@ -311,6 +315,9 @@ interface TextSecurePreferences { const val ALLOW_MESSAGE_REQUESTS = "libsession.ALLOW_MESSAGE_REQUESTS" + const val DEPRECATED_STATE_OVERRIDE = "deprecation_state_override" + const val DEPRECATED_TIME_OVERRIDE = "deprecated_time_override" + // Key name for if we've warned the user that saving attachments will allow other apps to access them. // Note: We only ever display this once - and when the user has accepted the warning we never show it again // for the lifetime of the Session installation. @@ -1692,4 +1699,24 @@ class AppTextSecurePreferences @Inject constructor( override fun setHidePassword(value: Boolean) { setBooleanPreference(HIDE_PASSWORD, value) } + + override var deprecationStateOverride: String? + get() = getStringPreference(TextSecurePreferences.DEPRECATED_STATE_OVERRIDE, null) + set(value) { + if (value == null) { + removePreference(TextSecurePreferences.DEPRECATED_STATE_OVERRIDE) + } else { + setStringPreference(TextSecurePreferences.DEPRECATED_STATE_OVERRIDE, value) + } + } + + override var deprecatedTimeOverride: ZonedDateTime? + get() = getStringPreference(TextSecurePreferences.DEPRECATED_TIME_OVERRIDE, null)?.let(ZonedDateTime::parse) + set(value) { + if (value == null) { + removePreference(TextSecurePreferences.DEPRECATED_TIME_OVERRIDE) + } else { + setStringPreference(TextSecurePreferences.DEPRECATED_TIME_OVERRIDE, value.toString()) + } + } }