From 3696e7cfcd74bcc1a25453850cd8ffba082cdf60 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 1 Oct 2026 21:47:27 +0300 Subject: [PATCH 1/3] Keep a diagonal slide to cancel an audio recording from locking it (fixes #372) --- .../composer/ui/ConversationComposeBarTest.kt | 57 +++++++++++++++++++ .../ui/ConversationSendActionButtonGesture.kt | 19 ++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt index 635206d6f..55da17428 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt @@ -601,6 +601,63 @@ class ConversationComposeBarTest { } } + @Test + fun longPressAndDragLeftDriftingUp_cancelsRecordingWithoutLocking() { + var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) + var lockRequests = 0 + var finishRequests = 0 + var cancelRequests = 0 + val cancelDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_CANCEL_THRESHOLD + 24.dp).toPx() + } + val lockDragDistancePx = with(composeTestRule.density) { + (AUDIO_RECORD_LOCK_THRESHOLD + 8.dp).toPx() + } + + setContent( + audioRecording = { audioRecording }, + messageText = { "" }, + isSendActionEnabled = false, + shouldShowRecordAction = { true }, + onAudioRecordingStartRequest = { + audioRecording = recordingAudioState() + }, + onAudioRecordingFinish = { + finishRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + onAudioRecordingLock = { + lockRequests += 1 + audioRecording = recordingAudioState(isLocked = true) + true + }, + onAudioRecordingCancel = { + cancelRequests += 1 + audioRecording = ConversationAudioRecordingUiState() + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .performTouchInput { + down(center) + advanceEventTime(durationMillis = 700L) + moveBy( + Offset( + x = -cancelDragDistancePx, + y = -lockDragDistancePx, + ), + ) + up() + } + + composeTestRule.runOnIdle { + assertEquals(0, lockRequests) + assertEquals(0, finishRequests) + assertEquals(1, cancelRequests) + } + } + @Test fun lockGesture_emitsConfirmHapticAndKeepsRecordingActiveUntilStopTap() { var audioRecording by mutableStateOf(ConversationAudioRecordingUiState()) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt index aa5ea6fb5..fc91fce2e 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt @@ -122,7 +122,7 @@ private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( val releaseGestureState = awaitRecordGestureRelease(initialDown = initialDown) { gestureState -> isRecordingLocked = updateRecordGestureLockState( - gestureState = gestureState, + gestureState = keepDominantDragDirection(gestureState = gestureState), isRecordingLocked = isRecordingLocked, lockThresholdPx = lockThresholdPx, onRecordGestureMove = onRecordGestureMove, @@ -136,7 +136,22 @@ private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( ) if (releaseGestureState != null && !isRecordingLocked) { - onRecordGestureFinish(releaseGestureState.cancelDragDistancePx >= cancelThresholdPx) + val cancelDragDistancePx = keepDominantDragDirection(gestureState = releaseGestureState) + .cancelDragDistancePx + + onRecordGestureFinish(cancelDragDistancePx >= cancelThresholdPx) + } +} + +private fun keepDominantDragDirection( + gestureState: ConversationSendActionButtonGestureState, +): ConversationSendActionButtonGestureState { + return when { + gestureState.cancelDragDistancePx > gestureState.lockDragDistancePx -> { + gestureState.copy(lockDragDistancePx = 0f) + } + + else -> gestureState.copy(cancelDragDistancePx = 0f) } } From 293ecb3b3f8fc8d906a710d3ada7b1b7375dd7c4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 1 Oct 2026 21:47:27 +0300 Subject: [PATCH 2/3] Show a snooze icon next to the title of a snoozed conversation (fixes #363) --- .../BaseConversationsRepositoryTest.kt | 9 +++ .../ConversationsRepositoryMetadataTest.kt | 60 ++++++++++++++++++ ...versationNotificationRepositoryImplTest.kt | 61 +++++++++++++++++++ .../testutil/ConversationMetadataFixtures.kt | 1 + ...nversationMetadataUiStateMapperImplTest.kt | 5 +- .../metadata/ui/ConversationTopAppBarTest.kt | 22 +++++++ .../model/metadata/ConversationMetadata.kt | 1 + .../repository/ConversationsRepository.kt | 32 +++++++--- .../ConversationNotificationRepository.kt | 27 ++++++++ .../ConversationMetadataUiStateMapper.kt | 1 + .../model/ConversationMetadataUiState.kt | 1 + .../metadata/ui/ConversationTopAppBar.kt | 55 ++++++++++++++--- 12 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepositoryImplTest.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt index 41b937d25..14fb96ff5 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/BaseConversationsRepositoryTest.kt @@ -11,12 +11,14 @@ import com.android.messaging.data.conversation.store.ConversationArchiveStore import com.android.messaging.data.conversation.store.ConversationPinStore import com.android.messaging.data.conversation.store.ConversationReadStore import com.android.messaging.data.conversation.store.ConversationSelfIdStore +import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.testutil.MainDispatcherRule import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Before import org.junit.Rule @@ -30,6 +32,8 @@ internal abstract class BaseConversationsRepositoryTest { protected lateinit var messageDetailsMapper: ConversationMessageDetailsMapper protected lateinit var messageDetailsPlatformSource: MessageDetailsPlatformSource protected lateinit var conversationArchiveStore: ConversationArchiveStore + protected lateinit var notificationRepository: ConversationNotificationRepository + protected val isSnoozedFlow = MutableStateFlow(value = false) @Before fun setUp() { @@ -37,6 +41,10 @@ internal abstract class BaseConversationsRepositoryTest { messageDetailsMapper = mockk(relaxed = true) messageDetailsPlatformSource = mockk(relaxed = true) conversationArchiveStore = mockk(relaxed = true) + notificationRepository = mockk { + every { observeIsSnoozed(conversationId = any()) } returns isSnoozedFlow + every { isSnoozed(conversationId = any()) } answers { isSnoozedFlow.value } + } } protected fun createRepository(): ConversationsRepositoryImpl { @@ -48,6 +56,7 @@ internal abstract class BaseConversationsRepositoryTest { conversationReadStore = mockk(relaxed = true), conversationPinStore = mockk(relaxed = true), conversationArchiveStore = conversationArchiveStore, + notificationRepository = notificationRepository, defaultDispatcher = mainDispatcherRule.testDispatcher, messagingDbDispatcher = mainDispatcherRule.testDispatcher, ) diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt index 6d4e94272..63ebe9050 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMetadataTest.kt @@ -3,6 +3,7 @@ package com.android.messaging.data.conversation.repository.conversations import android.database.ContentObserver import android.database.Cursor import android.database.MatrixCursor +import android.net.Uri import app.cash.turbine.test import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns @@ -16,6 +17,7 @@ import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID import com.android.messaging.testutil.assertThat import com.android.messaging.testutil.createParticipantsCursor import com.android.messaging.testutil.participantRow +import io.mockk.every import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -284,6 +286,64 @@ internal class ConversationsRepositoryMetadataTest : BaseConversationsRepository } } + @Test + fun getConversationMetadata_reEmitsWithSnoozeStateWhenSnoozeChanges() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri( + CONVERSATION_ID.value + ) + + stubObserverRegistration( + registeredObservers = mutableListOf(), + expectedUri = expectedUri, + ) + stubFreshMetadataCursorPerQuery(expectedUri = expectedUri) + + repository.getConversationMetadata(conversationId = CONVERSATION_ID).test { + assertEquals(false, awaitItem()?.isSnoozed) + + isSnoozedFlow.value = true + + assertEquals(true, awaitItem()?.isSnoozed) + cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun getConversationMetadataSnapshot_includesSnoozeState() { + runTest(context = mainDispatcherRule.testDispatcher) { + val repository = createRepository() + val expectedUri = MessagingContentProvider.buildConversationMetadataUri( + CONVERSATION_ID.value + ) + + stubFreshMetadataCursorPerQuery(expectedUri = expectedUri) + isSnoozedFlow.value = true + + val metadata = repository.getConversationMetadataSnapshot( + conversationId = CONVERSATION_ID, + ) + + assertEquals(true, metadata?.isSnoozed) + } + } + + private fun stubFreshMetadataCursorPerQuery(expectedUri: Uri) { + every { + contentResolver.query(expectedUri, any(), null, null, null) + } answers { + createConversationMetadataCursor( + row = conversationMetadataRow( + conversationName = "Carol, Dave", + selfParticipantId = "self-2", + participantCount = 2, + ), + ) + } + } + private fun createConversationMetadataCursor(row: TestConversationMetadataRow?): Cursor { val cursor = MatrixCursor(ConversationListItemData.PROJECTION) diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepositoryImplTest.kt new file mode 100644 index 000000000..b6aeb90b0 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepositoryImplTest.kt @@ -0,0 +1,61 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.android.messaging.data.conversationsettings.repository + +import app.cash.turbine.test +import com.android.messaging.FactoryTestAccess +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.testutil.FakeBuglePrefs +import com.android.messaging.testutil.installTestFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ConversationNotificationRepositoryImplTest { + + private val prefs = FakeBuglePrefs() + private val context = RuntimeEnvironment.getApplication() + private val repository = ConversationNotificationRepositoryImpl(context = context) + + @Before + fun setUp() { + installTestFactory(context = context, prefs = prefs) + } + + @After + fun tearDown() { + FactoryTestAccess.reset() + } + + @Test + fun observeIsSnoozed_turnsFalseWhenTheSnoozeExpires() = runTest { + prefs.putLong( + "${ConversationNotificationRepositoryImpl.SNOOZE_KEY_PREFIX}${CONVERSATION_ID.value}", + System.currentTimeMillis() + SNOOZE_MILLIS, + ) + + repository.observeIsSnoozed(conversationId = CONVERSATION_ID).test { + assertTrue(awaitItem()) + + advanceTimeBy(delayTimeMillis = SNOOZE_MILLIS / 2) + expectNoEvents() + + advanceTimeBy(delayTimeMillis = SNOOZE_MILLIS) + assertFalse(awaitItem()) + } + } + + private companion object { + val CONVERSATION_ID = ConversationId("conversation-1") + const val SNOOZE_MILLIS = 60 * 60 * 1000L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt b/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt index 59e0557fd..fc95d4b30 100644 --- a/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt +++ b/app/src/test/kotlin/com/android/messaging/testutil/ConversationMetadataFixtures.kt @@ -20,6 +20,7 @@ internal fun createConversationMetadata( otherParticipantPhotoUri = null, isArchived = false, isBlocked = false, + isSnoozed = false, composerAvailability = ConversationComposerAvailability.Editable, sortTimestamp = 0L, ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt index 817f29273..6c78074ca 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapperImplTest.kt @@ -18,7 +18,7 @@ class ConversationMetadataUiStateMapperImplTest { private val mapper = ConversationMetadataUiStateMapperImpl() @Test - fun map_oneOnOneConversation_usesSingleAvatarAndDisplayDestination() { + fun map_snoozedOneOnOneConversation_usesSingleAvatarDisplayDestinationAndSnooze() { val result = mapper.map( metadata = ConversationMetadata( conversationName = "Carol", @@ -32,6 +32,7 @@ class ConversationMetadataUiStateMapperImplTest { otherParticipantPhotoUri = "content://contacts/people/1/photo", isArchived = false, isBlocked = false, + isSnoozed = true, composerAvailability = ConversationComposerAvailability.Editable, sortTimestamp = 0L, ), @@ -52,6 +53,7 @@ class ConversationMetadataUiStateMapperImplTest { isArchived = false, isBlocked = false, composerAvailability = ConversationComposerAvailability.Editable, + isSnoozed = true, ) ) } @@ -71,6 +73,7 @@ class ConversationMetadataUiStateMapperImplTest { otherParticipantPhotoUri = "content://contacts/people/1/photo", isArchived = true, isBlocked = false, + isSnoozed = false, composerAvailability = ConversationComposerAvailability.Editable, sortTimestamp = 0L, ), diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt index 94dbb36d7..269293afa 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt @@ -148,6 +148,28 @@ class ConversationTopAppBarTest { .assertIsDisplayed() } + @Test + fun snoozedConversation_showsSnoozeIndicator() { + setContent(metadata = presentMetadata.copy(isSnoozed = true)) + + composeTestRule + .onNodeWithContentDescription( + label = targetContext.getString(R.string.conversation_list_status_snoozed), + ) + .assertIsDisplayed() + } + + @Test + fun conversationNotSnoozed_hidesSnoozeIndicator() { + setContent(metadata = presentMetadata.copy(isSnoozed = false)) + + composeTestRule + .onNodeWithContentDescription( + label = targetContext.getString(R.string.conversation_list_status_snoozed), + ) + .assertDoesNotExist() + } + @Test fun navigationIcon_forwardsBackClick() { var clicks = 0 diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index ba7f4ecbb..28ec2135a 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -14,6 +14,7 @@ internal data class ConversationMetadata( val otherParticipantPhotoUri: String?, val isArchived: Boolean, val isBlocked: Boolean, + val isSnoozed: Boolean, val composerAvailability: ConversationComposerAvailability, val sortTimestamp: Long, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 0c0ddf4f9..58484db50 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -18,6 +18,7 @@ import com.android.messaging.data.conversation.store.ConversationArchiveStore import com.android.messaging.data.conversation.store.ConversationPinStore import com.android.messaging.data.conversation.store.ConversationReadStore import com.android.messaging.data.conversation.store.ConversationSelfIdStore +import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider @@ -110,6 +111,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( private val conversationReadStore: ConversationReadStore, private val conversationPinStore: ConversationPinStore, private val conversationArchiveStore: ConversationArchiveStore, + private val notificationRepository: ConversationNotificationRepository, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @param:MessagingDbDispatcher @@ -121,12 +123,15 @@ internal class ConversationsRepositoryImpl @Inject constructor( ): Flow { val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId.value) - return observeUri(uri = uri) - .flowOn(defaultDispatcher) - .map { - queryConversationMetadata(uri = uri) - } - .flowOn(messagingDbDispatcher) + return combine( + observeUri(uri = uri).flowOn(defaultDispatcher), + notificationRepository.observeIsSnoozed(conversationId = conversationId), + ) { _, isSnoozed -> + queryConversationMetadata( + uri = uri, + isSnoozed = isSnoozed, + ) + }.flowOn(messagingDbDispatcher) } override suspend fun getConversationMetadataSnapshot( @@ -138,6 +143,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( return withContext(context = messagingDbDispatcher) { queryConversationMetadata( uri = uri, + isSnoozed = notificationRepository.isSnoozed(conversationId = conversationId), ) } } @@ -178,9 +184,14 @@ internal class ConversationsRepositoryImpl @Inject constructor( val metadata = when { conversationId.isBlank() -> null else -> { - MessagingContentProvider - .buildConversationMetadataUri(conversationId.value) - .let(::queryConversationMetadata) + queryConversationMetadata( + uri = MessagingContentProvider.buildConversationMetadataUri( + conversationId.value, + ), + isSnoozed = notificationRepository.isSnoozed( + conversationId = conversationId, + ), + ) } } @@ -358,7 +369,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( } } - private fun queryConversationMetadata(uri: Uri): ConversationMetadata? { + private fun queryConversationMetadata(uri: Uri, isSnoozed: Boolean): ConversationMetadata? { return contentResolver .query( uri, @@ -410,6 +421,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( ?.takeIf { it.isNotBlank() }, isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, isBlocked = otherParticipant?.isBlocked == true, + isSnoozed = isSnoozed, composerAvailability = ConversationComposerAvailability.Editable, sortTimestamp = cursor.getLong(ConversationColumns.SORT_TIMESTAMP), ) diff --git a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt index 4921081a6..ea1f4f3a1 100644 --- a/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt +++ b/src/com/android/messaging/data/conversationsettings/repository/ConversationNotificationRepository.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + package com.android.messaging.data.conversationsettings.repository import android.content.Context @@ -11,14 +13,22 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest internal interface ConversationNotificationRepository { fun observeSnoozeChanges(): Flow + fun observeIsSnoozed(conversationId: ConversationId): Flow + fun getSnoozeUntilMillis(conversationId: ConversationId): Long fun isSnoozed(conversationId: ConversationId): Boolean @@ -59,6 +69,23 @@ internal class ConversationNotificationRepositoryImpl @Inject constructor( } } + override fun observeIsSnoozed(conversationId: ConversationId): Flow { + return observeSnoozeChanges() + .onStart { emit(Unit) } + .transformLatest { + val remainingMillis = getSnoozeUntilMillis(conversationId) - + System.currentTimeMillis() + + emit(remainingMillis > 0L) + + if (remainingMillis > 0L) { + delay(remainingMillis.milliseconds) + emit(false) + } + } + .distinctUntilChanged() + } + override fun getSnoozeUntilMillis(conversationId: ConversationId): Long { val prefs = BuglePrefs.getApplicationPrefs() return prefs.getLong(snoozeKey(conversationId), SNOOZE_NOT_SET) diff --git a/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt index 009929d42..afc3b8881 100644 --- a/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -37,6 +37,7 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : isArchived = metadata.isArchived, isBlocked = metadata.isBlocked, composerAvailability = metadata.composerAvailability, + isSnoozed = metadata.isSnoozed, ) } } diff --git a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt index 950f8b5d2..b3cc09ec3 100644 --- a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt @@ -40,6 +40,7 @@ internal sealed interface ConversationMetadataUiState { val isArchived: Boolean, val isBlocked: Boolean, override val composerAvailability: ConversationComposerAvailability, + val isSnoozed: Boolean = false, ) : ConversationMetadataUiState @Immutable diff --git a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt index 81475ab10..6df260737 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Subject import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.NotificationsPaused import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Delete @@ -77,6 +79,8 @@ import com.android.messaging.util.AccessibilityUtil private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_FALLBACK_SIZE = 20.dp +private val CONVERSATION_TOP_APP_BAR_SNOOZE_ICON_SIZE = 16.dp +private val CONVERSATION_TOP_APP_BAR_SNOOZE_ICON_SPACING = 4.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -175,6 +179,7 @@ private fun rememberConversationTopAppBarPresentation( val avatar = conversationAvatar(metadata) val isBlocked = metadata is ConversationMetadataUiState.Present && metadata.isBlocked + val isSnoozed = metadata is ConversationMetadataUiState.Present && metadata.isSnoozed return remember( metadata, @@ -183,6 +188,7 @@ private fun rememberConversationTopAppBarPresentation( subtitleContentDescription, avatar, isBlocked, + isSnoozed, ) { ConversationTopAppBarPresentation( title = title, @@ -190,6 +196,7 @@ private fun rememberConversationTopAppBarPresentation( subtitleContentDescription = subtitleContentDescription, avatar = avatar, isBlocked = isBlocked, + isSnoozed = isSnoozed, ) } } @@ -239,13 +246,30 @@ private fun ConversationTopAppBarText( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center, ) { - Text( - text = presentation.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + horizontalArrangement = Arrangement.spacedBy( + space = CONVERSATION_TOP_APP_BAR_SNOOZE_ICON_SPACING, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(weight = 1f, fill = false), + text = presentation.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (presentation.isSnoozed) { + Icon( + modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_SNOOZE_ICON_SIZE), + imageVector = Icons.Default.NotificationsPaused, + contentDescription = stringResource(R.string.conversation_list_status_snoozed), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } if (presentation.subtitle != null) { Text( @@ -616,6 +640,7 @@ private data class ConversationTopAppBarPresentation( val subtitleContentDescription: String?, val avatar: ConversationMetadataUiState.Avatar, val isBlocked: Boolean, + val isSnoozed: Boolean, ) @Immutable @@ -687,6 +712,22 @@ private fun ConversationTopAppBarBlockedPreview() { } } +@PreviewLightDark +@Composable +private fun ConversationTopAppBarSnoozedPreview() { + MessagingPreviewTheme { + ConversationTopAppBar( + metadata = previewMetadata( + title = "Ada Lovelace, Countess of Lovelace and Analytical Engine Fan", + ).copy(isSnoozed = true), + isCallVisible = true, + onAddPeopleClick = {}, + onTitleClick = {}, + onNavigateBack = {}, + ) + } +} + @PreviewLightDark @Composable private fun ConversationTopAppBarGroupPreview() { From 4997dba78ef20982e4ea2d9cd8e34ceb11ef23a1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 1 Oct 2026 21:47:32 +0300 Subject: [PATCH 3/3] Offer Undo after archiving a conversation from inside it, including in shortcut and bubble windows (fixes #364) --- .../screen/BaseConversationScreenTest.kt | 5 +- ...BaseConversationScreenEffectsActionTest.kt | 4 +- .../action/ArchiveConversationImplTest.kt | 39 +++++ .../ConversationMetadataDelegateImplTest.kt | 35 +++- .../ConversationScreenArchiveUndoTest.kt | 156 ++++++++++++++++++ .../screen/ConversationScreenEffectsTest.kt | 1 + .../ConversationViewModelSimSelectionTest.kt | 1 + ...ionListNewestConversationVisibilityTest.kt | 3 + .../chats/ConversationListViewModelTest.kt | 49 ++++++ .../ConversationSettingsDelegateImplTest.kt | 34 +++- .../event/ConversationArchiveEvents.kt | 23 +++ .../conversation/ConversationLaunchModule.kt | 18 ++ .../usecase/action/ArchiveConversation.kt | 21 +++ .../delegate/ConversationMetadataDelegate.kt | 15 +- .../navigation/ConversationEntryNavState.kt | 3 + .../navigation/ConversationNavEntries.kt | 12 +- .../conversation/screen/ConversationScreen.kt | 3 + .../screen/ConversationScreenContent.kt | 21 ++- .../screen/ConversationScreenRoute.kt | 41 +++++ .../screen/ConversationViewModel.kt | 7 + .../model/ConversationScreenNavEvent.kt | 2 + .../chats/ConversationListViewModel.kt | 26 +++ .../ConversationSettingsNavEntries.kt | 11 +- .../delegate/ConversationSettingsDelegate.kt | 4 +- .../android/messaging/ui/host/AppNavGraph.kt | 12 +- 25 files changed, 525 insertions(+), 21 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/ArchiveConversationImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenArchiveUndoTest.kt create mode 100644 src/com/android/messaging/data/conversation/event/ConversationArchiveEvents.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/action/ArchiveConversation.kt diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt index ecd8aaa31..b4de5e749 100644 --- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/BaseConversationScreenTest.kt @@ -49,6 +49,7 @@ internal abstract class BaseConversationScreenTest { screenModel: ConversationScreenModel, conversationId: () -> ConversationId? = { CONVERSATION_ID }, cancelIncomingNotification: Boolean = true, + offersArchiveUndo: Boolean = false, lifecycleOwner: LifecycleOwner? = null, isScreenShown: () -> Boolean = { true }, onAddPeopleClick: () -> Unit = {}, @@ -60,6 +61,7 @@ internal abstract class BaseConversationScreenTest { onPendingStartupAttachmentConsumed: () -> Unit = {}, pendingScrollMessageId: MessageId? = null, onPendingScrollMessageIdConsumed: () -> Unit = {}, + onCloseConversation: () -> Unit = {}, ) { composeTestRule.setContent { val content: @Composable () -> Unit = content@{ @@ -70,6 +72,7 @@ internal abstract class BaseConversationScreenTest { ConversationScreen( conversationId = conversationId(), cancelIncomingNotification = cancelIncomingNotification, + offersArchiveUndo = offersArchiveUndo, onAddPeopleClick = onAddPeopleClick, onConversationDetailsClick = {}, onNavigateToMessageDetails = {}, @@ -78,7 +81,7 @@ internal abstract class BaseConversationScreenTest { onNavigateToAddContact = {}, onNavigateToForward = {}, onNavigateBack = {}, - onCloseConversation = {}, + onCloseConversation = onCloseConversation, pendingLaunchPayload = ConversationPendingLaunchPayload( draft = pendingDraft, scrollMessageId = pendingScrollMessageId, diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt index db4e86dad..8462d98a3 100644 --- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/screen/effects/BaseConversationScreenEffectsActionTest.kt @@ -137,7 +137,9 @@ internal abstract class BaseConversationScreenEffectsActionTest { onNavigateToMessageDetails(event.messageId) } - is ConversationScreenNavEvent.ForwardMessage -> Unit + ConversationScreenNavEvent.CloseAfterArchive, + is ConversationScreenNavEvent.ForwardMessage, + -> Unit } } ConversationScreenEffects( diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/ArchiveConversationImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/ArchiveConversationImplTest.kt new file mode 100644 index 000000000..aa4077ef8 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/action/ArchiveConversationImplTest.kt @@ -0,0 +1,39 @@ +package com.android.messaging.domain.conversation.usecase.action + +import app.cash.turbine.test +import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversation.repository.ConversationsRepository +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ArchiveConversationImplTest { + + @Test + fun invoke_notifiesConversationListAndArchives() { + runTest { + val conversationsRepository = mockk(relaxed = true) + val conversationArchiveEvents = ConversationArchiveEventsImpl() + val archiveConversation = ArchiveConversationImpl( + conversationsRepository = conversationsRepository, + conversationArchiveEvents = conversationArchiveEvents, + ) + + conversationArchiveEvents.archivedConversationIds.test { + archiveConversation(conversationId = CONVERSATION_ID) + + assertEquals(CONVERSATION_ID, awaitItem()) + } + coVerify(exactly = 1) { + conversationsRepository.archiveConversation(conversationId = CONVERSATION_ID) + } + } + } + + private companion object { + val CONVERSATION_ID = ConversationId("conversation-42") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt index 9f82daadb..7d02972c9 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt @@ -2,11 +2,13 @@ package com.android.messaging.ui.conversation.metadata.delegate import app.cash.turbine.test import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversation import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.testutil.TEST_CALL_ACTION_PHONE_NUMBER @@ -35,7 +37,7 @@ class ConversationMetadataDelegateImplTest { val mainDispatcherRule = MainDispatcherRule() @Test - fun onArchiveConversationClick_archivesViaRepositoryAndEmitsCloseConversation() { + fun onArchiveConversationClick_archivesAndEmitsCloseAfterArchive() { runTest(context = mainDispatcherRule.testDispatcher) { val harness = createHarness(conversationId = ConversationId("conversation-42")) @@ -44,13 +46,12 @@ class ConversationMetadataDelegateImplTest { harness.delegate.onArchiveConversationClick() advanceUntilIdle() - assertEquals(ConversationScreenNavEvent.CloseConversation, awaitItem()) + assertEquals(ConversationScreenNavEvent.CloseAfterArchive, awaitItem()) cancelAndIgnoreRemainingEvents() } coVerify(exactly = 1) { - harness.conversationsRepository - .archiveConversation(conversationId = ConversationId("conversation-42")) + harness.archiveConversation(conversationId = ConversationId("conversation-42")) } } finally { harness.cancel() @@ -82,6 +83,27 @@ class ConversationMetadataDelegateImplTest { } } + @Test + fun onUndoArchiveClick_unarchivesTheGivenConversation() { + runTest(context = mainDispatcherRule.testDispatcher) { + val harness = createHarness(conversationId = ConversationId("conversation-42")) + + try { + harness.delegate.onUndoArchiveClick( + conversationId = ConversationId("conversation-7"), + ) + advanceUntilIdle() + + coVerify(exactly = 1) { + harness.conversationsRepository + .unarchiveConversation(conversationId = ConversationId("conversation-7")) + } + } finally { + harness.cancel() + } + } + } + @Test fun archiveStatusClearedOutsideTheScreen_doesNotCloseConversation() { runTest(context = mainDispatcherRule.testDispatcher) { @@ -293,6 +315,7 @@ class ConversationMetadataDelegateImplTest { val mapper = mockk() val conversationIdFlow = MutableStateFlow(conversationId) val metadataFlow = MutableStateFlow(value = null) + val archiveConversation = mockk(relaxed = true) every { conversationsRepository.getConversationMetadata(any()) @@ -325,6 +348,8 @@ class ConversationMetadataDelegateImplTest { conversationsRepository = conversationsRepository, conversationMetadataUiStateMapper = mapper, blockedParticipantsRepository = mockk(relaxed = true), + conversationArchiveEvents = ConversationArchiveEventsImpl(), + archiveConversation = archiveConversation, defaultDispatcher = dispatcher, ) delegate.bind( @@ -335,6 +360,7 @@ class ConversationMetadataDelegateImplTest { return DelegateHarness( delegate = delegate, conversationsRepository = conversationsRepository, + archiveConversation = archiveConversation, metadataFlow = metadataFlow, scope = scope, ) @@ -357,6 +383,7 @@ class ConversationMetadataDelegateImplTest { private data class DelegateHarness( val delegate: ConversationMetadataDelegateImpl, val conversationsRepository: ConversationsRepository, + val archiveConversation: ArchiveConversation, val metadataFlow: MutableStateFlow, val scope: TestScope, ) { diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenArchiveUndoTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenArchiveUndoTest.kt new file mode 100644 index 000000000..18298ef17 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenArchiveUndoTest.kt @@ -0,0 +1,156 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import com.android.messaging.R +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.screen.model.ConversationScreenNavEvent +import io.mockk.every +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationScreenArchiveUndoTest : BaseConversationScreenTest() { + + @Test + fun closeAfterArchive_closesWhenTheHostHasAConversationList() { + val navigationEvents = MutableSharedFlow( + extraBufferCapacity = 1, + ) + val screenModel = createScreenModel().model + every { screenModel.navigationEvents } returns navigationEvents + var closeCount = 0 + + setContent( + screenModel = screenModel, + offersArchiveUndo = false, + onCloseConversation = { closeCount += 1 }, + ) + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + navigationEvents.subscriptionCount.value == 1 + } + + navigationEvents.tryEmit(ConversationScreenNavEvent.CloseAfterArchive) + + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + closeCount == 1 + } + } + + @Test + fun closeAfterArchive_staysOpenAndOffersUndoWithoutAConversationList() { + val navigationEvents = MutableSharedFlow( + extraBufferCapacity = 1, + ) + val archivedConversationIds = MutableSharedFlow(extraBufferCapacity = 1) + val screenModel = createScreenModel().model + every { screenModel.navigationEvents } returns navigationEvents + every { screenModel.archivedConversationIds } returns archivedConversationIds + var closeCount = 0 + + setContent( + screenModel = screenModel, + offersArchiveUndo = true, + onCloseConversation = { closeCount += 1 }, + ) + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + navigationEvents.subscriptionCount.value == 1 && + archivedConversationIds.subscriptionCount.value == 1 + } + + navigationEvents.tryEmit(ConversationScreenNavEvent.CloseAfterArchive) + archivedConversationIds.tryEmit(CONVERSATION_ID) + + val activity = composeTestRule.activity + composeTestRule + .onNodeWithText(activity.getString(R.string.archived_toast_message, 1)) + .assertExists() + composeTestRule + .onNodeWithText(activity.getString(R.string.snack_bar_undo)) + .performClick() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + screenModel.onUndoArchiveClick(conversationId = CONVERSATION_ID) + } + assertEquals(0, closeCount) + } + } + + @Test + fun archivedBeforeComposition_undoSurvivesTheInitialScrollToLatest() { + val handle = createScreenModel() + val screenModel = handle.model + every { screenModel.archivedConversationIds } returns flowOf(CONVERSATION_ID) + handle.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 3, + latestMessageId = "message-3", + latestMessageIncoming = false, + ), + ) + + setContent( + screenModel = screenModel, + offersArchiveUndo = true, + ) + + composeTestRule + .onNodeWithText(composeTestRule.activity.getString(R.string.archived_toast_message, 1)) + .assertExists() + } + + @Test + fun archiveWhileTheNewMessageNoticeIsShown_replacesItWithUndo() { + val archivedConversationIds = MutableSharedFlow(extraBufferCapacity = 1) + val handle = createScreenModel() + val screenModel = handle.model + every { screenModel.archivedConversationIds } returns archivedConversationIds + handle.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 30, + latestMessageId = "message-30", + latestMessageIncoming = false, + ), + ) + setContent( + screenModel = screenModel, + offersArchiveUndo = true, + ) + composeTestRule + .onNodeWithTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .performTouchInput { swipeDown() } + composeTestRule.waitForIdle() + handle.scaffoldUiStateFlow.value = createPresentUiState( + messages = createMessages( + count = 31, + latestMessageId = "message-31", + latestMessageIncoming = true, + ), + ) + val activity = composeTestRule.activity + composeTestRule + .onNodeWithText(activity.getString(R.string.in_conversation_notify_new_message_text)) + .assertExists() + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + archivedConversationIds.subscriptionCount.value == 1 + } + + archivedConversationIds.tryEmit(CONVERSATION_ID) + + composeTestRule + .onNodeWithText(activity.getString(R.string.archived_toast_message, 1)) + .assertExists() + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt index ee250ebc4..e46ed5f66 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/ConversationScreenEffectsTest.kt @@ -108,6 +108,7 @@ class ConversationScreenEffectsTest { CollectEvents(events = screenModel.navigationEvents) { event -> when (event) { is NavEvent.CloseConversation -> onNavigateBack() + is NavEvent.CloseAfterArchive -> Unit is NavEvent.NavigateToMessageDetails -> Unit is NavEvent.ForwardMessage -> Unit } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt index 269f2d1c2..59ef32287 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt @@ -125,6 +125,7 @@ internal class ConversationViewModelSimSelectionTest { } returns MutableStateFlow(false) every { conversationMetadataDelegate.effects } returns emptyFlow() every { conversationMetadataDelegate.navigationEvents } returns emptyFlow() + every { conversationMetadataDelegate.archivedConversationIds } returns emptyFlow() every { conversationMetadataDelegate.bind(any(), any()) } just runs every { conversationFocusDelegate.bind(any(), any()) } just runs diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt index d04a408c3..5d026c115 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListNewestConversationVisibilityTest.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import com.android.common.test.helpers.targetContext import com.android.messaging.FactoryTestAccess +import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.testutil.TestLifecycleOwner @@ -186,7 +187,9 @@ internal class ConversationListNewestConversationVisibilityTest { optimisticSnapshotDelegate = optimisticSnapshotDelegate, resolveContactAction = mockk(relaxed = true), debugFeaturesProvider = mockk(relaxed = true), + conversationArchiveEvents = ConversationArchiveEventsImpl(), defaultDispatcher = Dispatchers.Main, + mainDispatcher = Dispatchers.Main, ) } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt index 1e4f0e9f4..581b66377 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversationlist.chats import app.cash.turbine.test +import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.conversationlist.repository.ConversationListRepository @@ -30,6 +31,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Rule @@ -48,6 +50,7 @@ class ConversationListViewModelTest { private val optimisticSnapshotDelegate = mockk() private val debugFeaturesProvider = mockk() private val resolveContactAction = mockk() + private val conversationArchiveEvents = ConversationArchiveEventsImpl() private val snapshotFlow = MutableStateFlow(null) private val selectedIdsFlow = MutableStateFlow>( @@ -88,6 +91,50 @@ class ConversationListViewModelTest { } } + @Test + fun conversationArchivedOutsideList_removesItAndOffersUndoWithoutPersistingAgain() { + runTest(context = mainDispatcherRule.testDispatcher) { + val viewModel = createViewModel() + runCurrent() + + viewModel.effects.test { + conversationArchiveEvents.notifyArchived(conversationId = ConversationId("a")) + + assertThat(awaitItem()).isEqualTo( + Effect.ArchiveStatusChanged( + conversationIds = persistentListOf(ConversationId("a")), + isArchived = true, + ) + ) + cancelAndIgnoreRemainingEvents() + } + advanceUntilIdle() + + verify { optimisticSnapshotDelegate.remove(listOf(ConversationId("a"))) } + coVerify(exactly = 0) { actionsDelegate.setArchived(any(), any()) } + } + } + + @Test + fun conversationArchivedBeforeListExists_offersUndoOnceListStarts() { + runTest(context = mainDispatcherRule.testDispatcher) { + conversationArchiveEvents.notifyArchived(conversationId = ConversationId("a")) + + val viewModel = createViewModel() + viewModel.effects.test { + assertThat(awaitItem()).isEqualTo( + Effect.ArchiveStatusChanged( + conversationIds = persistentListOf(ConversationId("a")), + isArchived = true, + ) + ) + expectNoEvents() + } + + verify { optimisticSnapshotDelegate.remove(listOf(ConversationId("a"))) } + } + } + @Test fun swipeToggleRead_marksUnreadConversationReadOptimisticallyAndPersists() { runTest(context = mainDispatcherRule.testDispatcher) { @@ -261,7 +308,9 @@ class ConversationListViewModelTest { optimisticSnapshotDelegate = optimisticSnapshotDelegate, debugFeaturesProvider = debugFeaturesProvider, resolveContactAction = resolveContactAction, + conversationArchiveEvents = conversationArchiveEvents, defaultDispatcher = mainDispatcherRule.testDispatcher, + mainDispatcher = mainDispatcherRule.testDispatcher, ) } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt index 92dea5d72..a89570819 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt @@ -12,6 +12,7 @@ import com.android.messaging.data.conversationsettings.repository.ConversationSe import com.android.messaging.data.subscription.model.SubId import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversation import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantId import com.android.messaging.ui.conversationsettings.screen.CONVERSATION_SETTINGS_CONVERSATION_ID_ARG import com.android.messaging.ui.conversationsettings.screen.mapper.ConversationSettingsUiStateMapperImpl @@ -34,6 +35,8 @@ internal class ConversationSettingsDelegateImplTest { private val subscriptionsRepository = mockk() private val setConversationSelfParticipantId = mockk(relaxed = true) + private val conversationsRepository = mockk(relaxed = true) + private val archiveConversation = mockk(relaxed = true) @Test fun bind_conversationBoundToDefaultSelf_showsDefaultSmsSubscription() { @@ -96,6 +99,34 @@ internal class ConversationSettingsDelegateImplTest { } } + @Test + fun setArchived_archiving_archivesThroughUseCase() { + runTest { + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.setArchived(archived = true) + runCurrent() + + coVerify(exactly = 1) { archiveConversation(conversationId = CONVERSATION_ID) } + coVerify(exactly = 0) { conversationsRepository.unarchiveConversation(any()) } + } + } + + @Test + fun setArchived_unarchiving_unarchivesViaRepository() { + runTest { + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.setArchived(archived = false) + runCurrent() + + coVerify(exactly = 1) { + conversationsRepository.unarchiveConversation(CONVERSATION_ID) + } + coVerify(exactly = 0) { archiveConversation(conversationId = any()) } + } + } + private fun stubConversationSelfParticipantId(selfParticipantId: ParticipantId) { every { settingsRepository.getConversationSettings(CONVERSATION_ID) } returns flowOf( ConversationSettingsData( @@ -121,8 +152,9 @@ internal class ConversationSettingsDelegateImplTest { canShowOrAddContact = { _, _, _, _ -> false }, isContactSavedUseCase = { _, _ -> false }, ), - conversationsRepository = mockk(relaxed = true), + conversationsRepository = conversationsRepository, blockedParticipantsRepository = mockk(relaxed = true), + archiveConversation = archiveConversation, setConversationSelfParticipantId = setConversationSelfParticipantId, applicationScope = applicationScope, savedStateHandle = SavedStateHandle( diff --git a/src/com/android/messaging/data/conversation/event/ConversationArchiveEvents.kt b/src/com/android/messaging/data/conversation/event/ConversationArchiveEvents.kt new file mode 100644 index 000000000..6d90787fc --- /dev/null +++ b/src/com/android/messaging/data/conversation/event/ConversationArchiveEvents.kt @@ -0,0 +1,23 @@ +package com.android.messaging.data.conversation.event + +import com.android.messaging.data.conversation.model.ConversationId +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow + +internal interface ConversationArchiveEvents { + val archivedConversationIds: Flow + + fun notifyArchived(conversationId: ConversationId) +} + +internal class ConversationArchiveEventsImpl @Inject constructor() : ConversationArchiveEvents { + + private val _archivedConversationIds = Channel(Channel.BUFFERED) + override val archivedConversationIds = _archivedConversationIds.receiveAsFlow() + + override fun notifyArchived(conversationId: ConversationId) { + _archivedConversationIds.trySend(conversationId) + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationLaunchModule.kt b/src/com/android/messaging/di/conversation/ConversationLaunchModule.kt index dfbce561a..ebe797f0c 100644 --- a/src/com/android/messaging/di/conversation/ConversationLaunchModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationLaunchModule.kt @@ -1,17 +1,35 @@ package com.android.messaging.di.conversation +import com.android.messaging.data.conversation.event.ConversationArchiveEvents +import com.android.messaging.data.conversation.event.ConversationArchiveEventsImpl +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversation +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversationImpl import com.android.messaging.ui.conversation.entry.ConversationLaunchStore import com.android.messaging.ui.conversation.entry.ConversationLaunchStoreImpl import com.android.messaging.ui.conversation.navigation.ConversationDraftLauncher import dagger.Binds import dagger.Module +import dagger.Reusable import dagger.hilt.InstallIn import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.hilt.android.scopes.ActivityRetainedScoped @Module @InstallIn(ActivityRetainedComponent::class) internal abstract class ConversationLaunchModule { + @Binds + @ActivityRetainedScoped + abstract fun bindConversationArchiveEvents( + impl: ConversationArchiveEventsImpl, + ): ConversationArchiveEvents + + @Binds + @Reusable + abstract fun bindArchiveConversation( + impl: ArchiveConversationImpl, + ): ArchiveConversation + @Binds abstract fun bindConversationLaunchStore( impl: ConversationLaunchStoreImpl, diff --git a/src/com/android/messaging/domain/conversation/usecase/action/ArchiveConversation.kt b/src/com/android/messaging/domain/conversation/usecase/action/ArchiveConversation.kt new file mode 100644 index 000000000..c82b85993 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/action/ArchiveConversation.kt @@ -0,0 +1,21 @@ +package com.android.messaging.domain.conversation.usecase.action + +import com.android.messaging.data.conversation.event.ConversationArchiveEvents +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversation.repository.ConversationsRepository +import javax.inject.Inject + +internal fun interface ArchiveConversation { + suspend operator fun invoke(conversationId: ConversationId) +} + +internal class ArchiveConversationImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val conversationArchiveEvents: ConversationArchiveEvents, +) : ArchiveConversation { + + override suspend fun invoke(conversationId: ConversationId) { + conversationArchiveEvents.notifyArchived(conversationId = conversationId) + conversationsRepository.archiveConversation(conversationId = conversationId) + } +} diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt index 944c14806..1c2868371 100644 --- a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -2,10 +2,12 @@ package com.android.messaging.ui.conversation.metadata.delegate import com.android.messaging.R import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.event.ConversationArchiveEvents import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversation import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult import com.android.messaging.ui.conversation.common.ConversationScreenDelegate @@ -32,10 +34,12 @@ internal interface ConversationMetadataDelegate : ConversationScreenDelegate { val effects: Flow val navigationEvents: Flow + val archivedConversationIds: Flow val isDeleteConversationConfirmationVisible: StateFlow fun onArchiveConversationClick() fun onUnarchiveConversationClick() + fun onUndoArchiveClick(conversationId: ConversationId) fun onUnblockConversationClick() fun onAddContactClick() fun onDeleteConversationClick() @@ -48,6 +52,8 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, private val blockedParticipantsRepository: BlockedParticipantsRepository, + conversationArchiveEvents: ConversationArchiveEvents, + private val archiveConversation: ArchiveConversation, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ConversationMetadataDelegate { @@ -63,6 +69,7 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( override val effects = _effects.asSharedFlow() override val navigationEvents = _navigationEvents.asSharedFlow() + override val archivedConversationIds = conversationArchiveEvents.archivedConversationIds override val state = _state.asStateFlow() override val isDeleteConversationConfirmationVisible = _isDeleteConversationConfirmationVisible.asStateFlow() @@ -121,14 +128,18 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( val conversationId = currentConversationId ?: return boundScope?.launch(defaultDispatcher) { - conversationsRepository.archiveConversation(conversationId = conversationId) - _navigationEvents.emit(NavEvent.CloseConversation) + archiveConversation(conversationId = conversationId) + _navigationEvents.emit(NavEvent.CloseAfterArchive) } } override fun onUnarchiveConversationClick() { val conversationId = currentConversationId ?: return + onUndoArchiveClick(conversationId = conversationId) + } + + override fun onUndoArchiveClick(conversationId: ConversationId) { boundScope?.launch(defaultDispatcher) { conversationsRepository.unarchiveConversation(conversationId = conversationId) } diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationEntryNavState.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationEntryNavState.kt index 39190deef..ecac350e4 100644 --- a/src/com/android/messaging/ui/conversation/navigation/ConversationEntryNavState.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationEntryNavState.kt @@ -10,17 +10,20 @@ import com.android.messaging.ui.conversation.entry.ConversationEntryViewModel internal data class ConversationEntryNavState( val model: ConversationEntryScreenModel, val isLaunchedFromBubble: Boolean, + val hasConversationList: Boolean, ) @Composable internal fun ProvideConversationEntryNavState( isLaunchedFromBubble: Boolean, + hasConversationList: Boolean, content: @Composable () -> Unit, ) { val entryModel: ConversationEntryScreenModel = hiltViewModel() val entryNavState = ConversationEntryNavState( model = entryModel, isLaunchedFromBubble = isLaunchedFromBubble, + hasConversationList = hasConversationList, ) CompositionLocalProvider( diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationNavEntries.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavEntries.kt index 6886dfce9..221b0fa31 100644 --- a/src/com/android/messaging/ui/conversation/navigation/ConversationNavEntries.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavEntries.kt @@ -64,6 +64,7 @@ private fun conversationScreenRouteContent(): @Composable (ConversationNavKey) - ConversationRoute( conversationId = navKey.conversationId, isLaunchedFromBubble = entryNavState.isLaunchedFromBubble, + hasConversationList = entryNavState.hasConversationList, entryModel = entryNavState.model, entryUiState = entryUiState, ) @@ -75,20 +76,18 @@ private fun conversationScreenRouteContent(): @Composable (ConversationNavKey) - private fun ConversationRoute( conversationId: ConversationId, isLaunchedFromBubble: Boolean, + hasConversationList: Boolean, entryModel: ConversationEntryScreenModel, entryUiState: ConversationEntryUiState, ) { val navigator = rememberConversationNavigator() val appNavigator = LocalNavigator.current - val pendingPayload = pendingLaunchPayloadForConversation( - entryUiState = entryUiState, - conversationId = conversationId, - ) ConversationScreen( screenModel = hiltViewModel(), conversationId = conversationId, cancelIncomingNotification = !isLaunchedFromBubble, + offersArchiveUndo = !hasConversationList, onAddPeopleClick = { navigator.navigateToAddParticipants(conversationId = conversationId) }, @@ -123,7 +122,10 @@ private fun ConversationRoute( onCloseConversation = { appNavigator.closeConversation(conversationId = conversationId) }, - pendingLaunchPayload = pendingPayload, + pendingLaunchPayload = pendingLaunchPayloadForConversation( + entryUiState = entryUiState, + conversationId = conversationId, + ), onPendingDraftConsumed = { entryModel.onDraftPayloadConsumed(conversationId = conversationId) }, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index b91079c99..1760becd1 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -26,11 +26,13 @@ import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaf import com.android.messaging.ui.photoviewer.model.PhotoViewerLaunchRequest @Composable +@Suppress("LongMethod") // Long arguments list, not too much logic, so it's ok to suppress internal fun ConversationScreen( screenModel: ConversationScreenModel, modifier: Modifier = Modifier, conversationId: ConversationId? = null, cancelIncomingNotification: Boolean = true, + offersArchiveUndo: Boolean = false, onAddPeopleClick: () -> Unit, onConversationDetailsClick: () -> Unit, onNavigateToMessageDetails: (messageId: MessageId) -> Unit, @@ -66,6 +68,7 @@ internal fun ConversationScreen( ConversationScreenRouteEffects( conversationId = conversationId, cancelIncomingNotification = cancelIncomingNotification, + offersArchiveUndo = offersArchiveUndo, pendingLaunchPayload = pendingLaunchPayload, scaffoldUiState = scaffoldUiState, snackbarHostState = snackbarHostState, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt index 2869ac519..4c5bfbe74 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SnackbarVisuals import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -57,6 +58,15 @@ private data class ConversationLatestScrollSnapshot( val isListDragged: Boolean, ) +// Its own type, so scrolling to the latest message dismisses this notice and leaves others alone. +private class NewMessageSnackbarVisuals( + override val message: String, + override val actionLabel: String, +) : SnackbarVisuals { + override val withDismissAction: Boolean = false + override val duration: SnackbarDuration = SnackbarDuration.Indefinite +} + @Composable internal fun ConversationScreenContent( modifier: Modifier = Modifier, @@ -357,9 +367,10 @@ private fun AutoScrollToLatestMessage( if (autoScrollDecision.shouldShowNewMessageSnackbar) { val snackbarResult = snackbarHostState.showSnackbar( - message = newMessageText, - actionLabel = viewActionLabel, - duration = SnackbarDuration.Indefinite, + visuals = NewMessageSnackbarVisuals( + message = newMessageText, + actionLabel = viewActionLabel, + ), ) if (snackbarResult == SnackbarResult.ActionPerformed) { @@ -390,7 +401,9 @@ private fun TrackLatestMessageScrollState( when { scrollSnapshot.isScrolledToLatestMessage -> { onWasScrolledToLatestMessageChanged(true) - snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.currentSnackbarData + ?.takeIf { it.visuals is NewMessageSnackbarVisuals } + ?.dismiss() } scrollSnapshot.isListDragged -> { diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index 995374c41..e9e1e4278 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -18,11 +18,14 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalResources import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleResumeEffect +import com.android.messaging.R import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.MessageId +import com.android.messaging.ui.common.components.snackbar.showActionSnackbar import com.android.messaging.ui.contact.model.AddContactRequest import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.mediapicker.ConversationMediaPickerOverlay @@ -92,6 +95,7 @@ internal fun rememberAudioRecordingStartRequest( internal fun ConversationScreenRouteEffects( conversationId: ConversationId?, cancelIncomingNotification: Boolean, + offersArchiveUndo: Boolean, pendingLaunchPayload: ConversationPendingLaunchPayload, scaffoldUiState: ConversationScreenScaffoldUiState, snackbarHostState: SnackbarHostState, @@ -135,6 +139,13 @@ internal fun ConversationScreenRouteEffects( when (event) { ConversationScreenNavEvent.CloseConversation -> onCloseConversation() + ConversationScreenNavEvent.CloseAfterArchive -> { + when { + offersArchiveUndo -> Unit + else -> onCloseConversation() + } + } + is ConversationScreenNavEvent.NavigateToMessageDetails -> { onNavigateToMessageDetails(event.messageId) } @@ -145,6 +156,13 @@ internal fun ConversationScreenRouteEffects( } } + if (offersArchiveUndo) { + ConversationArchiveUndoEffect( + screenModel = screenModel, + snackbarHostState = snackbarHostState, + ) + } + ConversationScreenEffects( screenModel = screenModel, snackbarHostState = snackbarHostState, @@ -155,6 +173,29 @@ internal fun ConversationScreenRouteEffects( ) } +// Without an inbox to return to, archiving keeps the conversation open and offers Undo here. +@Composable +private fun ConversationArchiveUndoEffect( + screenModel: ConversationScreenModel, + snackbarHostState: SnackbarHostState, +) { + val resources = LocalResources.current + + CollectEvents(events = screenModel.archivedConversationIds) { conversationId -> + // An Indefinite notice (new message) would otherwise hold Undo back. + snackbarHostState.currentSnackbarData?.dismiss() + + val undoClicked = snackbarHostState.showActionSnackbar( + message = resources.getString(R.string.archived_toast_message, 1), + actionLabel = resources.getString(R.string.snack_bar_undo), + ) + + if (undoClicked) { + screenModel.onUndoArchiveClick(conversationId = conversationId) + } + } +} + @Composable private fun ConversationPendingLaunchEffects( conversationId: ConversationId?, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index af57ff81c..dd0f65ba7 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -62,6 +62,7 @@ internal interface ConversationScreenModel : ConversationMessageSelectionActions { val effects: Flow val navigationEvents: Flow + val archivedConversationIds: Flow val mediaPickerOverlayUiState: StateFlow val scaffoldUiState: StateFlow @@ -116,6 +117,7 @@ internal interface ConversationScreenModel : fun onArchiveConversationClick() fun onUnarchiveConversationClick() + fun onUndoArchiveClick(conversationId: ConversationId) fun onUnblockClick() fun onAddContactClick() fun onDeleteConversationClick() @@ -168,6 +170,7 @@ internal class ConversationViewModel @Inject constructor( override val effects = _effects.asSharedFlow() override val navigationEvents = _navigationEvents.asSharedFlow() + override val archivedConversationIds = conversationMetadataDelegate.archivedConversationIds init { initializeDelegates() @@ -697,6 +700,10 @@ internal class ConversationViewModel @Inject constructor( conversationMetadataDelegate.onUnarchiveConversationClick() } + override fun onUndoArchiveClick(conversationId: ConversationId) { + conversationMetadataDelegate.onUndoArchiveClick(conversationId = conversationId) + } + override fun onUnblockClick() { conversationMetadataDelegate.onUnblockConversationClick() } diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenNavEvent.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenNavEvent.kt index b5848796b..f95fb48c5 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenNavEvent.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenNavEvent.kt @@ -6,6 +6,8 @@ internal sealed interface ConversationScreenNavEvent { data object CloseConversation : ConversationScreenNavEvent + data object CloseAfterArchive : ConversationScreenNavEvent + data class NavigateToMessageDetails( val messageId: MessageId, ) : ConversationScreenNavEvent diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index 8a2f19c4b..05c067be4 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversationlist.chats import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversation.event.ConversationArchiveEvents import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMode @@ -10,6 +11,7 @@ import com.android.messaging.data.conversationlist.repository.ConversationListRe import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.di.core.MainDispatcher import com.android.messaging.domain.conversation.usecase.participant.ResolveContactAction import com.android.messaging.domain.conversation.usecase.participant.model.ResolveContactActionResult import com.android.messaging.ui.contact.model.AddContactRequest @@ -24,6 +26,7 @@ import com.android.messaging.ui.conversationlist.delegate.ConversationListSelect import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel @@ -34,6 +37,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -55,8 +59,11 @@ internal class ConversationListViewModel @Inject constructor( private val optimisticSnapshotDelegate: ConversationListOptimisticSnapshotDelegate, private val resolveContactAction: ResolveContactAction, private val debugFeaturesProvider: DebugFeaturesProvider, + private val conversationArchiveEvents: ConversationArchiveEvents, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, ) : ViewModel(), ConversationListScreenModel { @@ -107,6 +114,7 @@ internal class ConversationListViewModel @Inject constructor( scope = viewModelScope, snapshot = snapshot, ) + offerUndoForArchivesOutsideList() } override fun onAction(action: Action) { @@ -361,6 +369,24 @@ internal class ConversationListViewModel @Inject constructor( ) } + private fun offerUndoForArchivesOutsideList() { + viewModelScope.launch(mainDispatcher) { + conversationArchiveEvents + .archivedConversationIds + .map(::persistentListOf) + .collect { conversationIds -> + optimisticSnapshotDelegate.remove(conversationIds) + + _effects.trySend( + Effect.ArchiveStatusChanged( + conversationIds = conversationIds, + isArchived = true, + ), + ) + } + } + } + private fun onConversationSwipedToToggleRead(conversationId: ConversationId) { val item = itemById(conversationId) ?: return diff --git a/src/com/android/messaging/ui/conversationsettings/navigation/ConversationSettingsNavEntries.kt b/src/com/android/messaging/ui/conversationsettings/navigation/ConversationSettingsNavEntries.kt index 480c4592f..e0ff6211c 100644 --- a/src/com/android/messaging/ui/conversationsettings/navigation/ConversationSettingsNavEntries.kt +++ b/src/com/android/messaging/ui/conversationsettings/navigation/ConversationSettingsNavEntries.kt @@ -7,6 +7,7 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.android.messaging.R import com.android.messaging.ui.contact.navigation.navigateToAddContact +import com.android.messaging.ui.conversation.navigation.LocalConversationEntryNavState import com.android.messaging.ui.conversation.navigation.rememberConversationNavigator import com.android.messaging.ui.conversationsettings.screen.ConversationSettingsScreen import com.android.messaging.ui.conversationsettings.screen.ConversationSettingsViewModel @@ -25,6 +26,7 @@ internal fun EntryProviderScope.conversationSettingsEntries() { private fun conversationSettingsRouteContent(): @Composable (ConversationSettingsNavKey) -> Unit { return { navKey -> val navigator = LocalNavigator.current + val entryNavState = LocalConversationEntryNavState.current val conversationNavigator = rememberConversationNavigator() val effectHandler = rememberConversationSettingsEffectHandler( onNavigateToAddContact = { request -> @@ -41,7 +43,14 @@ private fun conversationSettingsRouteContent(): @Composable (ConversationSetting effectHandler = effectHandler, onNavigateBack = navigator::back, onCloseAfterArchive = { - navigator.closeConversation(conversationId = navKey.conversationId) + when { + entryNavState.hasConversationList -> { + navigator.closeConversation(conversationId = navKey.conversationId) + } + + // The conversation stays open and offers Undo. + else -> navigator.back() + } }, onNavigateToConversation = { conversationId -> conversationNavigator.navigateToConversation(conversationId = conversationId) diff --git a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt index c6bcc1d7d..71e773e0e 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt @@ -11,6 +11,7 @@ import com.android.messaging.data.conversationsettings.repository.ConversationSe import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.ParticipantRefresh import com.android.messaging.di.core.ApplicationCoroutineScope +import com.android.messaging.domain.conversation.usecase.action.ArchiveConversation import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantId import com.android.messaging.ui.conversationsettings.common.ConversationSettingsScreenDelegate import com.android.messaging.ui.conversationsettings.screen.CONVERSATION_SETTINGS_CONVERSATION_ID_ARG @@ -47,6 +48,7 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( private val mapper: ConversationSettingsUiStateMapper, private val conversationsRepository: ConversationsRepository, private val blockedParticipantsRepository: BlockedParticipantsRepository, + private val archiveConversation: ArchiveConversation, private val setConversationSelfParticipantId: SetConversationSelfParticipantId, @param:ApplicationCoroutineScope private val applicationScope: CoroutineScope, savedStateHandle: SavedStateHandle, @@ -121,7 +123,7 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( applicationScope.launch { when { - archived -> conversationsRepository.archiveConversation(conversationId) + archived -> archiveConversation(conversationId = conversationId) else -> conversationsRepository.unarchiveConversation(conversationId) } } diff --git a/src/com/android/messaging/ui/host/AppNavGraph.kt b/src/com/android/messaging/ui/host/AppNavGraph.kt index db6e20b3f..833271ed0 100644 --- a/src/com/android/messaging/ui/host/AppNavGraph.kt +++ b/src/com/android/messaging/ui/host/AppNavGraph.kt @@ -3,12 +3,15 @@ package com.android.messaging.ui.host import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.SceneStrategy import com.android.messaging.domain.onboarding.usecase.SelfPhoneNumberPermissionPrompt import com.android.messaging.ui.conversation.navigation.ProvideConversationEntryNavState +import com.android.messaging.ui.conversationlist.navigation.ConversationListNavKey import com.android.messaging.ui.navigation.AppNavDisplay import com.android.messaging.ui.navigation.LocalNavigator import com.android.messaging.ui.navigation.NavigationReducer @@ -39,6 +42,10 @@ internal fun AppNavGraph( onFinish = onFinish, ) val entryProvider = remember { appNavEntryProvider() } + // A shortcut or bubble host has no inbox until forwarding resets the stack to one. + val hasConversationList by remember(backStack) { + derivedStateOf { ConversationListNavKey in backStack } + } AppResumeEffect( backStack = backStack, @@ -59,7 +66,10 @@ internal fun AppNavGraph( ) CompositionLocalProvider(LocalNavigator provides navigator) { - ProvideConversationEntryNavState(isLaunchedFromBubble = isLaunchedFromBubble) { + ProvideConversationEntryNavState( + isLaunchedFromBubble = isLaunchedFromBubble, + hasConversationList = hasConversationList, + ) { AppNavDisplay( backStack = backStack, entryProvider = entryProvider,