diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt index 048cd1fd51..f0b5c0b9f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt @@ -153,7 +153,7 @@ fun DisappearingMessages( .fadingEdges(scrollState), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - state.cards.filter { it.options.isNotEmpty() }.forEach { + state.cards.forEach { OptionsCard(it) } 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 9901915203..f4424f62ee 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 @@ -1,7 +1,10 @@ package org.thoughtcrime.securesms.conversation.expiration +import android.app.Application import android.content.Context import androidx.annotation.StringRes +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -10,9 +13,11 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import network.loki.messenger.BuildConfig @@ -71,14 +76,14 @@ object NoOpCallbacks: Callbacks class ExpirationSettingsViewModel( private val threadId: Long, - private val context: Context, + private val application: Application, private val textSecurePreferences: TextSecurePreferences, private val messageExpirationManager: MessageExpirationManagerProtocol, private val threadDb: ThreadDatabase, private val groupDb: GroupDatabase, private val storage: Storage, isNewConfigEnabled: Boolean -) : ViewModel(), Callbacks { +) : AndroidViewModel(application), Callbacks { private val _event = Channel() val event = _event.receiveAsFlow() @@ -89,7 +94,9 @@ class ExpirationSettingsViewModel( )) val state = _state.asStateFlow() - val uiState = _state.map { UiState(it) } + val uiState = _state + .map(::UiState) + .stateIn(viewModelScope, SharingStarted.Eagerly, UiState()) private var expirationConfig: ExpirationConfiguration? = null @@ -161,7 +168,7 @@ class ExpirationSettingsViewModel( MessageSender.send(message, address) } - ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context) + ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(application) _event.send(Event.SUCCESS) } @@ -174,7 +181,7 @@ class ExpirationSettingsViewModel( @Suppress("UNCHECKED_CAST") class Factory @AssistedInject constructor( @Assisted private val threadId: Long, - @ApplicationContext private val context: Context, + private val application: Application, private val textSecurePreferences: TextSecurePreferences, private val messageExpirationManager: MessageExpirationManagerProtocol, private val threadDb: ThreadDatabase, @@ -184,7 +191,7 @@ class ExpirationSettingsViewModel( override fun create(modelClass: Class): T = ExpirationSettingsViewModel( threadId, - context, + application, textSecurePreferences, messageExpirationManager, threadDb, @@ -201,9 +208,9 @@ data class UiState( val callbacks: Callbacks = NoOpCallbacks ) { constructor(state: State): this( - cards = listOf( - CardModel(GetString(R.string.activity_expiration_settings_delete_type), typeOptions(state)), - CardModel(GetString(R.string.activity_expiration_settings_timer), timeOptions(state)) + cards = listOfNotNull( + typeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_delete_type), it) }, + timeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_timer), it) } ), showGroupFooter = state.isGroup && state.isNewConfigEnabled, callbacks = state.callbacks @@ -216,39 +223,42 @@ data class CardModel( ) private fun typeOptions(state: State) = - if (state.isNoteToSelf || (state.isGroup && state.isNewConfigEnabled)) emptyList() - else listOfNotNull( - typeOption( - ExpiryType.NONE, - state, - R.string.expiration_off, - contentDescription = R.string.AccessibilityId_disable_disappearing_messages, - enabled = state.isSelfAdmin - ), - if (!state.isNewConfigEnabled) typeOption( - ExpiryType.LEGACY, - state, - R.string.expiration_type_disappear_legacy, - contentDescription = R.string.expiration_type_disappear_legacy_description, - enabled = state.isSelfAdmin - ) else null, - if (!state.isGroup) typeOption( - ExpiryType.AFTER_READ, - 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 = state.isNewConfigEnabled && state.isSelfAdmin - ) else null, - typeOption( - ExpiryType.AFTER_SEND, - 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 = state.isNewConfigEnabled && state.isSelfAdmin - ), - ) + state.takeUnless { + state.isNoteToSelf || state.isGroup && state.isNewConfigEnabled + }?.run { + listOfNotNull( + typeOption( + ExpiryType.NONE, + state, + R.string.expiration_off, + contentDescription = R.string.AccessibilityId_disable_disappearing_messages, + enabled = state.isSelfAdmin + ), + if (!state.isNewConfigEnabled) typeOption( + ExpiryType.LEGACY, + state, + R.string.expiration_type_disappear_legacy, + contentDescription = R.string.expiration_type_disappear_legacy_description, + enabled = state.isSelfAdmin + ) else null, + if (!state.isGroup) typeOption( + ExpiryType.AFTER_READ, + 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 = state.isNewConfigEnabled && state.isSelfAdmin + ) else null, + typeOption( + ExpiryType.AFTER_SEND, + 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 = state.isNewConfigEnabled && state.isSelfAdmin + ) + ) + } private fun typeOption( type: ExpiryType, @@ -266,17 +276,17 @@ private fun typeOption( onClick = onClick ) -private fun timeOptions(state: State): List = +private fun timeOptions(state: State) = if (state.isNoteToSelf || (state.isGroup && state.isNewConfigEnabled)) timeOptionsOnly(state) else when (state.expiryMode) { is ExpiryMode.Legacy -> afterReadTimes is ExpiryMode.AfterRead -> afterReadTimes is ExpiryMode.AfterSend -> afterSendTimes - else -> emptyList() - }.map { timeOption(it, state) } + else -> null + }?.map { timeOption(it, state) } -private val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) -private val afterReadTimes = listOf(5.minutes, 1.hours) + afterSendTimes +val afterSendTimes = listOf(12.hours, 1.days, 7.days, 14.days) +val afterReadTimes = listOf(5.minutes, 1.hours) + afterSendTimes private fun timeOptionsOnly(state: State) = listOfNotNull( typeOption(ExpiryType.NONE, state, R.string.arrays__off, enabled = state.isSelfAdmin), @@ -312,9 +322,9 @@ enum class ExpiryType(private val createMode: (Long) -> ExpiryMode) { fun mode(seconds: Long) = createMode(seconds) fun defaultMode() = when(this) { - AFTER_READ -> 43200L - else -> 86400L - }.let { mode(it) } + AFTER_READ -> 12.hours + else -> 1.days + }.inWholeSeconds.let(::mode) } private val ExpiryMode.type: ExpiryType get() = when(this) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt new file mode 100644 index 0000000000..2e13ce1df6 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/MainCoroutineRule.kt @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainCoroutineRule(private val dispatcher: TestDispatcher = StandardTestDispatcher()) : + TestWatcher() { + + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} 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 new file mode 100644 index 0000000000..67b7aeabb1 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.conversation.expiration + +import android.app.Application +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import org.session.libsession.messaging.messages.ExpirationConfiguration +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.GroupRecord +import org.session.libsession.utilities.GroupUtil.CLOSED_GROUP_PREFIX +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 kotlin.time.Duration.Companion.hours + +@OptIn(ExperimentalCoroutinesApi::class) +class ExpirationSettingsViewModelTest { + + @ExperimentalCoroutinesApi + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + val application: Application = mock(Application::class.java) + val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java) + val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java) + val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java) + val groupDb: GroupDatabase = mock(GroupDatabase::class.java) + val storage: Storage = mock(Storage::class.java) + + val recipient = mock(Recipient::class.java) + + val groupRecord = mock(GroupRecord::class.java) + val optionalGroupRecord = Optional.of(groupRecord) + + @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) + + val address = Address.fromSerialized("${CLOSED_GROUP_PREFIX}94198734289") + + whenever(recipient.isClosedGroupRecipient).thenReturn(true) + whenever(recipient.address).thenReturn(address) + + whenever(groupDb.getGroup(Mockito.anyString())).thenReturn(optionalGroupRecord) + + val viewModel = createViewModel() + + advanceUntilIdle() + + val state = viewModel.state.value + + MatcherAssert.assertThat( + state.isGroup, + CoreMatchers.equalTo(true) + ) + + MatcherAssert.assertThat( + viewModel.uiState.value.cards.count(), + CoreMatchers.equalTo(1) + ) + + MatcherAssert.assertThat( + viewModel.uiState.value.cards[0].options.count(), + CoreMatchers.equalTo(6) + ) + } + + private fun createViewModel(isNewConfigEnabled: Boolean = true) = ExpirationSettingsViewModel( + 1L, + application, + textSecurePreferences, + messageExpirationManager, + threadDb, + groupDb, + storage, + isNewConfigEnabled + ) +}