diff --git a/app/build.gradle b/app/build.gradle index 9a0678f326..1046ba201f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -319,6 +319,7 @@ dependencies { // Assertions androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:truth:1.5.0' + testImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3' // Espresso dependencies diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt index 5235c45d89..44e6e3fbc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt @@ -48,7 +48,7 @@ data class State( val isSelfAdmin: Boolean = true, val address: Address? = null, val isNoteToSelf: Boolean = false, - val expiryMode: ExpiryMode? = ExpiryMode.NONE, + val expiryMode: ExpiryMode? = null, val isNewConfigEnabled: Boolean = true, val persistedMode: ExpiryMode? = null, val showDebugOptions: Boolean = false @@ -58,11 +58,10 @@ data class State( else -> GetString(R.string.activity_expiration_settings_subtitle) } - val modeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) + val typeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) val duration get() = expiryMode?.duration val expiryType get() = expiryMode?.type - val expiryTypeOrNone: ExpiryType = expiryType ?: ExpiryType.NONE val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) } @@ -104,15 +103,12 @@ class ExpirationSettingsViewModel( .map(::UiState) .stateIn(viewModelScope, SharingStarted.Eagerly, UiState()) - private var expirationConfig: ExpirationConfiguration? = null - init { viewModelScope.launch { - expirationConfig = storage.getExpirationConfiguration(threadId) - val expiryMode = expirationConfig?.expiryMode ?: ExpiryMode.NONE + val expiryMode = storage.getExpirationConfiguration(threadId)?.expiryMode ?: ExpiryMode.NONE val recipient = threadDb.getRecipientForThreadId(threadId) val groupRecord = recipient?.takeIf { it.isClosedGroupRecipient } - ?.run { address.toGroupString().let(groupDb::getGroup).orNull() } + ?.run { groupDb.getGroup(address.toGroupString()).orNull() } _state.update { state -> state.copy( @@ -127,23 +123,13 @@ class ExpirationSettingsViewModel( } } - override fun setMode(mode: ExpiryMode) { - _state.update { it.copy( - expiryMode = mode - ) } - } + override fun setMode(mode: ExpiryMode) = _state.update { it.copy(expiryMode = mode) } override fun onSetClick() = viewModelScope.launch { val state = _state.value - val mode = state.expiryMode.let { - when { - it !is ExpiryMode.Legacy -> it - state.isGroup -> ExpiryMode.AfterSend(it.expirySeconds) - else -> ExpiryMode.AfterRead(it.expirySeconds) - } ?: ExpiryMode.NONE - } + val mode = state.expiryMode val address = state.address - if (address == null) { + if (address == null || mode == null) { _event.send(Event.FAIL) return@launch } @@ -194,12 +180,6 @@ class ExpirationSettingsViewModel( } } -private fun ExpiryType.defaultMode(persistedMode: ExpiryMode?) = when(this) { - persistedMode?.type -> persistedMode - ExpiryType.AFTER_READ -> mode(12.hours) - else -> mode(1.days) -} - data class UiState( val cards: List = emptyList(), val showGroupFooter: Boolean = false @@ -211,62 +191,43 @@ data class UiState( ), showGroupFooter = state.isGroup && state.isNewConfigEnabled ) + + constructor(showGroupFooter: Boolean, vararg cards: CardModel): this(cards.asList(), showGroupFooter) } data class CardModel( val title: GetString, val options: List -) +) { + constructor(title: GetString, vararg options: OptionModel): this(title, options.asList()) + constructor(@StringRes title: Int, vararg options: OptionModel): this(GetString(title), options.asList()) +} -private fun typeOptions(state: State) = - state.takeUnless { it.modeOptionsHidden }?.run { - listOfNotNull( - typeOption( - ExpiryMode.NONE, - state, - R.string.expiration_off, - contentDescription = R.string.AccessibilityId_disable_disappearing_messages, - enabled = isSelfAdmin - ), - if (!isNewConfigEnabled) typeOption( - ExpiryType.LEGACY.defaultMode(persistedMode), - state, - R.string.expiration_type_disappear_legacy, - contentDescription = R.string.expiration_type_disappear_legacy_description, - enabled = isSelfAdmin - ) else null, - if (!isGroup) typeOption( - ExpiryType.AFTER_READ.defaultMode(persistedMode), - state, - R.string.expiration_type_disappear_after_read, - R.string.expiration_type_disappear_after_read_description, - contentDescription = R.string.expiration_type_disappear_after_read_description, - enabled = isNewConfigEnabled && isSelfAdmin - ) else null, - typeOption( - ExpiryType.AFTER_SEND.defaultMode(persistedMode), - state, - R.string.expiration_type_disappear_after_send, - R.string.expiration_type_disappear_after_read_description, - contentDescription = R.string.expiration_type_disappear_after_send_description, - enabled = isNewConfigEnabled && isSelfAdmin - ) - ) - } +fun offTypeOption(state: State) = typeOption(ExpiryType.NONE, state) +fun legacyTypeOption(state: State) = typeOption(ExpiryType.LEGACY, state) +fun afterReadTypeOption(state: State) = newTypeOption(ExpiryType.AFTER_READ, state) +fun afterSendTypeOption(state: State) = newTypeOption(ExpiryType.AFTER_SEND, state) +private fun newTypeOption(type: ExpiryType, state: State) = typeOption(type, state, state.run { isNewConfigEnabled && isSelfAdmin }) + +private fun typeOptions(state: State) = state.takeUnless { it.typeOptionsHidden }?.run { + listOfNotNull( + offTypeOption(state), + takeUnless { isNewConfigEnabled }?.let(::legacyTypeOption), + takeUnless { isGroup }?.let(::afterReadTypeOption), + afterSendTypeOption(state) + ) +} private fun typeOption( - mode: ExpiryMode, + type: ExpiryType, state: State, - @StringRes title: Int, - @StringRes subtitle: Int? = null, - @StringRes contentDescription: Int = title, - enabled: Boolean = true, + enabled: Boolean = state.isSelfAdmin, ) = OptionModel( - value = mode, - title = GetString(title), - subtitle = subtitle?.let(::GetString), - contentDescription = GetString(contentDescription), - selected = state.expiryType == mode.type, + value = type.defaultMode(state.persistedMode), + title = GetString(type.title), + subtitle = type.subtitle?.let(::GetString), + contentDescription = GetString(type.contentDescription), + selected = state.expiryType == type, enabled = enabled ) @@ -292,26 +253,26 @@ val afterReadModes = afterReadTimes.map { it.inWholeSeconds }.map(ExpiryMode::Af fun afterReadOptions(state: State) = afterReadModes.map { timeOption(it, state) } private fun timeOptions(state: State): List? = - if (state.modeOptionsHidden) timeOptionsAfterSend(state) + if (state.typeOptionsHidden) timeOptionsAfterSend(state) else when (state.expiryMode) { is ExpiryMode.Legacy, is ExpiryMode.AfterRead -> debugOptions(state) + afterReadOptions(state) is ExpiryMode.AfterSend -> debugOptions(state) + afterSendOptions(state) else -> null } -private fun timeOptionsAfterSend(state: State) = listOf( - typeOption(ExpiryMode.NONE, state, R.string.expiration_off, enabled = state.isSelfAdmin), -) + debugOptions(state) + afterSendModes.map { timeOption(it, state) } +private fun timeOptionsAfterSend(state: State): List = + listOf(offTypeOption(state)) + debugOptions(state) + afterSendOptions(state) -private fun timeOption( +fun timeOption( mode: ExpiryMode, state: State, - title: GetString = GetString(mode.duration, ExpirationUtil::getExpirationDisplayValue), + title: GetString = GetString(mode.duration), subtitle: GetString? = null, ) = OptionModel( value = mode, title = title, subtitle = subtitle, + contentDescription = title, selected = state.expiryMode == mode, enabled = state.isTimeOptionsEnabled ) @@ -325,14 +286,43 @@ data class OptionModel( val enabled: Boolean = true, ) -enum class ExpiryType(private val createMode: (Long) -> ExpiryMode) { - NONE({ ExpiryMode.NONE }), - LEGACY(ExpiryMode::Legacy), - AFTER_SEND(ExpiryMode::AfterSend), - AFTER_READ(ExpiryMode::AfterRead); +enum class ExpiryType( + private val createMode: (Long) -> ExpiryMode, + @StringRes val title: Int, + @StringRes val subtitle: Int? = null, + @StringRes val contentDescription: Int = title, +) { + NONE( + { ExpiryMode.NONE }, + R.string.expiration_off, + contentDescription = R.string.AccessibilityId_disable_disappearing_messages, + ), + LEGACY( + ExpiryMode::Legacy, + R.string.expiration_type_disappear_legacy, + contentDescription = R.string.expiration_type_disappear_legacy_description + ), + AFTER_SEND( + ExpiryMode::AfterSend, + R.string.expiration_type_disappear_after_send, + R.string.expiration_type_disappear_after_read_description, + R.string.expiration_type_disappear_after_send_description + ), + AFTER_READ( + ExpiryMode::AfterRead, + R.string.expiration_type_disappear_after_read, + R.string.expiration_type_disappear_after_read_description, + R.string.expiration_type_disappear_after_read_description + ); fun mode(seconds: Long) = if (seconds != 0L) createMode(seconds) else ExpiryMode.NONE fun mode(duration: Duration) = mode(duration.inWholeSeconds) + + fun defaultMode(persistedMode: ExpiryMode?) = when(this) { + persistedMode?.type -> persistedMode + AFTER_READ -> mode(12.hours) + else -> mode(1.days) + } } val ExpiryMode.type: ExpiryType get() = when(this) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt index 2cd246cf7d..f80acee64e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/RadioOptionAdapter.kt @@ -120,6 +120,3 @@ class RadioOptionBuilder( contentDescription ) } - -typealias StringRadioOption = RadioOption -typealias ExpirationRadioOption = RadioOption diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt index a7373ccc0e..adfe2d5a3b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt @@ -1,132 +1,188 @@ package org.thoughtcrime.securesms.conversation.expiration import android.app.Application +import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import network.loki.messenger.R import network.loki.messenger.libsession_util.util.ExpiryMode -import org.hamcrest.CoreMatchers -import org.hamcrest.MatcherAssert import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito.mock +import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.whenever import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.utilities.Address import org.session.libsession.utilities.SSKEnvironment import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient -import org.session.libsignal.utilities.guava.Optional import org.thoughtcrime.securesms.MainCoroutineRule import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.ui.GetString -import kotlin.time.Duration.Companion.hours -import network.loki.messenger.R +import kotlin.reflect.typeOf import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +private const val THREAD_ID = 1L + @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) class ExpirationSettingsViewModelTest { @ExperimentalCoroutinesApi @get:Rule var mainCoroutineRule = MainCoroutineRule() - private val application: Application = mock(Application::class.java) - private val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java) - private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java) - private val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java) - private val groupDb: GroupDatabase = mock(GroupDatabase::class.java) - private val storage: Storage = mock(Storage::class.java) - private val recipient = mock(Recipient::class.java) + @Mock lateinit var application: Application + @Mock lateinit var textSecurePreferences: TextSecurePreferences + @Mock lateinit var messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol + @Mock lateinit var threadDb: ThreadDatabase + @Mock lateinit var groupDb: GroupDatabase + @Mock lateinit var storage: Storage + @Mock lateinit var recipient: Recipient @Test - fun `UI should show a list of times and an Off option`() = runTest { - val threadId = 1L - - val expirationConfig = ExpirationConfiguration( - threadId = threadId, - expiryMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), - updatedTimestampMs = 0 - ) - whenever(threadDb.getRecipientForThreadId(Mockito.anyLong())).thenReturn(recipient) - whenever(storage.getExpirationConfiguration(Mockito.anyLong())).thenReturn(expirationConfig) - whenever(textSecurePreferences.getLocalNumber()).thenReturn("05---LOCAL---ADDRESS") - - val userAddress = Address.fromSerialized(textSecurePreferences.getLocalNumber()!!) + fun `1-1 conversation, 12 hours after send, new config`() = runTest { + val time = 12.hours val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + val config = newExpirationConfiguration(time) + whenever(threadDb.getRecipientForThreadId(Mockito.anyLong())).thenReturn(recipient) + whenever(storage.getExpirationConfiguration(Mockito.anyLong())).thenReturn(config) + whenever(textSecurePreferences.getLocalNumber()).thenReturn("05---LOCAL---ADDRESS") whenever(recipient.isClosedGroupRecipient).thenReturn(false) whenever(recipient.address).thenReturn(someAddress) - whenever(groupDb.getGroup(Mockito.anyString())).thenReturn(Optional.absent()) - val viewModel = createViewModel() advanceUntilIdle() - MatcherAssert.assertThat( - viewModel.state.value, - CoreMatchers.equalTo( - State( - isGroup = false, - isSelfAdmin = true, - address = someAddress, - isNoteToSelf = false, - expiryMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), - isNewConfigEnabled = true, - persistedMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), - showDebugOptions = false + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(12.hours.inWholeSeconds), + showDebugOptions = false + ) + ) + + val newTypeOption = TypeOptionCreator(time) + val newTimeOption = TimeOptionCreator(ExpiryType.AFTER_SEND) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + showGroupFooter = false, + CardModel( + R.string.activity_expiration_settings_delete_type, + newTypeOption(ExpiryType.NONE), + newTypeOption(ExpiryType.AFTER_READ), + newTypeOption(ExpiryType.AFTER_SEND, selected = true) + ), + CardModel( + GetString(R.string.activity_expiration_settings_timer), + newTimeOption(duration = 12.hours, selected = true), + newTimeOption(duration = 1.days), + newTimeOption(duration = 7.days), + newTimeOption(duration = 14.days) ) ) ) + } - val uiState = viewModel.uiState.value + @Test + fun `1-1 conversation, 1 day after send, new config`() = runTest { + val time = 1.days + val someAddress = Address.fromSerialized("05---SOME---ADDRESS") + val config = newExpirationConfiguration(time) - MatcherAssert.assertThat( - uiState.cards.map { it.title }, - CoreMatchers.equalTo( - listOf( + whenever(threadDb.getRecipientForThreadId(Mockito.anyLong())).thenReturn(recipient) + whenever(storage.getExpirationConfiguration(Mockito.anyLong())).thenReturn(config) + whenever(textSecurePreferences.getLocalNumber()).thenReturn("05---LOCAL---ADDRESS") + whenever(recipient.isClosedGroupRecipient).thenReturn(false) + whenever(recipient.address).thenReturn(someAddress) + + val viewModel = createViewModel() + + advanceUntilIdle() + + assertThat( + viewModel.state.value + ).isEqualTo( + State( + isGroup = false, + isSelfAdmin = true, + address = someAddress, + isNoteToSelf = false, + expiryMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + isNewConfigEnabled = true, + persistedMode = ExpiryMode.AfterSend(1.days.inWholeSeconds), + showDebugOptions = false + ) + ) + + val newTypeOption = TypeOptionCreator(time) + val newTimeOption = TimeOptionCreator(ExpiryType.AFTER_SEND) + + assertThat( + viewModel.uiState.value + ).isEqualTo( + UiState( + showGroupFooter = false, + CardModel( R.string.activity_expiration_settings_delete_type, - R.string.activity_expiration_settings_timer - ).map(::GetString) + newTypeOption(ExpiryType.NONE), + typeOption(12.hours, ExpiryType.AFTER_READ), + newTypeOption(ExpiryType.AFTER_SEND, selected = true) + ), + CardModel( + GetString(R.string.activity_expiration_settings_timer), + newTimeOption(duration = 12.hours), + newTimeOption(duration = 1.days, selected = true), + newTimeOption(duration = 7.days), + newTimeOption(duration = 14.days) + ) ) ) + } - MatcherAssert.assertThat( - uiState.cards[0].options.map { it.title }, - CoreMatchers.equalTo( - listOf( - R.string.expiration_off, - R.string.expiration_type_disappear_after_read, - R.string.expiration_type_disappear_after_send, - ).map(::GetString) - ) - ) + private fun newExpirationConfiguration(time: Duration) = ExpirationConfiguration( + threadId = THREAD_ID, + expiryMode = ExpiryMode.AfterSend(time.inWholeSeconds), + updatedTimestampMs = 0 + ) - MatcherAssert.assertThat( - uiState.cards[1].options.map { it.title }, - CoreMatchers.equalTo( - listOf( - 12.hours, - 1.days, - 7.days, - 14.days, - ).map(::GetString) - ) - ) + private class TypeOptionCreator(private val time: Duration) { + operator fun invoke(type: ExpiryType, selected: Boolean = false, enabled: Boolean = true) = + typeOption(time, type, selected, enabled) + } - MatcherAssert.assertThat( - uiState.showGroupFooter, - CoreMatchers.equalTo(false) + private class TimeOptionCreator(private val type: ExpiryType) { + operator fun invoke(duration: Duration, selected: Boolean = false, enabled: Boolean = true) = OptionModel( + value = type.mode(duration), + title = GetString(duration), + enabled = enabled, + selected = selected ) } private fun createViewModel(isNewConfigEnabled: Boolean = true) = ExpirationSettingsViewModel( - 1L, + THREAD_ID, application, textSecurePreferences, messageExpirationManager, @@ -134,6 +190,16 @@ class ExpirationSettingsViewModelTest { groupDb, storage, isNewConfigEnabled, - false + showDebugOptions = false ) } + +fun typeOption(time: Duration, type: ExpiryType, selected: Boolean = false, enabled: Boolean = true) = + OptionModel( + type.mode(time), + GetString(type.title), + type.subtitle?.let(::GetString), + GetString(type.contentDescription), + selected = selected, + enabled = enabled + )