diff --git a/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt index 30ace08d3..2de45c81b 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversationlist/store/ConversationListStatusStoreTest.kt @@ -3,9 +3,12 @@ package com.android.messaging.data.conversationlist.store import com.android.messaging.data.secondaryuser.SecondaryUserNotifier import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.SyncManager +import com.android.messaging.datamodel.action.MarkAsSeenAction import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify import org.junit.After @@ -24,9 +27,11 @@ internal class ConversationListStatusStoreTest { @Before fun setUp() { mockkStatic(DataModel::class) + mockkStatic(MarkAsSeenAction::class) every { DataModel.get() } returns dataModel every { dataModel.syncManager } returns syncManager + every { MarkAsSeenAction.markAllAsSeen(any()) } just runs } @After @@ -46,6 +51,7 @@ internal class ConversationListStatusStoreTest { store.setNewestConversationVisible(isVisible = true) verify { dataModel.isConversationListScrolledToNewestConversation = true } + verify(exactly = 1) { MarkAsSeenAction.markAllAsSeen(false) } verify { secondaryUserNotifier.cancel() } } @@ -56,6 +62,9 @@ internal class ConversationListStatusStoreTest { verify(exactly = 1) { dataModel.isConversationListScrolledToNewestConversation = false } + verify(exactly = 0) { + MarkAsSeenAction.markAllAsSeen(any()) + } verify(exactly = 0) { secondaryUserNotifier.cancel() } diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/action/MarkAsSeenActionTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/action/MarkAsSeenActionTest.kt new file mode 100644 index 000000000..fe5d192b9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/datamodel/action/MarkAsSeenActionTest.kt @@ -0,0 +1,157 @@ +package com.android.messaging.datamodel.action + +import android.content.Context +import android.database.Cursor +import androidx.core.content.contentValuesOf +import com.android.messaging.FactoryTestAccess +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.testutil.installTestFactory +import com.android.messaging.util.PendingIntentConstants +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +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 +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +internal class MarkAsSeenActionTest { + + private lateinit var context: Context + private lateinit var database: DatabaseWrapper + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication().applicationContext + + val dataModel = mockk(relaxed = true) + installTestFactory(context = context, dataModel = dataModel) + database = createInMemoryActionSyncTestDatabase(context) + every { dataModel.database } returns database + + mockkStatic(BugleNotifications::class) + every { BugleNotifications.cancel(any(), any()) } just Runs + every { BugleNotifications.update(any()) } just Runs + } + + @After + fun tearDown() { + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun executeAction_withoutCancelling_marksAllSeenAndUpdatesNotifications() { + insertUnseenMessage( + conversationName = "First conversation", + recipient = "+15551230000", + ) + insertUnseenMessage( + conversationName = "Second conversation", + recipient = "+15551230001", + ) + + MarkAsSeenAction(null, false).executeAction() + + assertEquals(0, countUnseenMessages()) + verify(exactly = 1) { + BugleNotifications.update(BugleNotifications.UPDATE_MESSAGES) + } + verify(exactly = 0) { + BugleNotifications.cancel(any(), any()) + } + } + + @Test + fun executeAction_withoutChanges_doesNotUpdateOrCancelNotifications() { + MarkAsSeenAction(null, false).executeAction() + + verify(exactly = 0) { + BugleNotifications.update(any()) + } + verify(exactly = 0) { + BugleNotifications.cancel(any(), any()) + } + } + + @Test + fun publicConstructor_defaultsToCancellingNotifications() { + MarkAsSeenAction(null as String?).executeAction() + + verify(exactly = 1) { + BugleNotifications.cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, null) + } + verify(exactly = 0) { + BugleNotifications.update(any()) + } + } + + private fun insertUnseenMessage(conversationName: String, recipient: String) { + val participantId = database.insert( + DatabaseHelper.PARTICIPANTS_TABLE, + null, + contentValuesOf( + DatabaseHelper.ParticipantColumns.NORMALIZED_DESTINATION to recipient, + ), + ) + assertTrue("participant insert failed", participantId >= 0) + + val conversationId = database.insert( + DatabaseHelper.CONVERSATIONS_TABLE, + null, + contentValuesOf(DatabaseHelper.ConversationColumns.NAME to conversationName), + ) + assertTrue("conversation insert failed", conversationId >= 0) + + val messageId = database.insert( + DatabaseHelper.MESSAGES_TABLE, + null, + contentValuesOf( + DatabaseHelper.MessageColumns.CONVERSATION_ID to conversationId, + DatabaseHelper.MessageColumns.SENDER_PARTICIPANT_ID to participantId, + DatabaseHelper.MessageColumns.SELF_PARTICIPANT_ID to participantId, + DatabaseHelper.MessageColumns.STATUS to MessageData.BUGLE_STATUS_INCOMING_COMPLETE, + DatabaseHelper.MessageColumns.SEEN to 0, + DatabaseHelper.MessageColumns.READ to 0, + DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP to 1L, + DatabaseHelper.MessageColumns.SENT_TIMESTAMP to 1L, + ), + ) + assertTrue("message insert failed", messageId >= 0) + } + + private fun countUnseenMessages(): Int { + return database.query( + DatabaseHelper.MESSAGES_TABLE, + arrayOf("COUNT(*)"), + "${DatabaseHelper.MessageColumns.SEEN}=0", + null, + null, + null, + null, + ).use { cursor -> cursor.readCount() } + } + + private fun Cursor.readCount(): Int { + return when { + moveToFirst() -> getInt(0) + else -> 0 + } + } +} diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/action/SyncMessageBatchSeenTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/action/SyncMessageBatchSeenTest.kt new file mode 100644 index 000000000..02bf24203 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/datamodel/action/SyncMessageBatchSeenTest.kt @@ -0,0 +1,226 @@ +package com.android.messaging.datamodel.action + +import android.database.Cursor +import android.database.MatrixCursor +import android.provider.Telephony.Mms +import android.provider.Telephony.Sms +import androidx.core.content.contentValuesOf +import com.android.messaging.FactoryTestAccess +import com.android.messaging.datamodel.ActionSyncTestDataModel +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.DatabaseHelper +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.MessageColumns +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.MessageNotificationState.FailedMessageQuery +import com.android.messaging.datamodel.SyncManager.ThreadInfoCache +import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.mmslib.pdu.PduHeaders +import com.android.messaging.sms.DatabaseMessages +import com.android.messaging.testutil.installTestFactory +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +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 +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = SyncMessagesActionMmsReplacementRaceTestApplication::class) +internal class SyncMessageBatchSeenTest { + + private lateinit var dataModel: ActionSyncTestDataModel + private lateinit var database: DatabaseWrapper + private lateinit var conversationId: String + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication().applicationContext + dataModel = ActionSyncTestDataModel() + installTestFactory(context = context, dataModel = dataModel) + database = createInMemoryActionSyncTestDatabase(context) + dataModel.database = database + conversationId = database.insert( + DatabaseHelper.CONVERSATIONS_TABLE, + null, + contentValuesOf(ConversationColumns.NAME to "Imported"), + ).toString() + } + + @After + fun tearDown() { + FactoryTestAccess.reset() + } + + @Test + fun readSmsIsImportedAsSeen() { + sync(smsToAdd = listOf(sms(isRead = true))) + + assertTrue(readImportedSeen()) + } + + @Test + fun unreadSmsIsImportedAsUnseenSoItStillNotifies() { + sync(smsToAdd = listOf(sms(isRead = false))) + + assertFalse(readImportedSeen()) + } + + @Test + fun unreadSmsSyncedWhileInboxShowsNewestIsImportedAsSeen() { + dataModel.isConversationListScrolledToNewestConversation = true + + sync(smsToAdd = listOf(sms(isRead = false))) + + assertTrue(readImportedSeen()) + } + + @Test + fun readMmsIsImportedAsSeen() { + sync(mmsToAdd = listOf(mms(isRead = true))) + + assertTrue(readImportedSeen()) + } + + // The provider marks every non-inbox row read, so read says nothing about a send failure. + @Test + fun failedOutgoingSmsStillNotifiesTheFailure() { + dataModel.isConversationListScrolledToNewestConversation = true + + sync(smsToAdd = listOf(sms(isRead = true, type = Sms.MESSAGE_TYPE_FAILED))) + + assertEquals(1, countFailureNotificationCandidates()) + } + + @Test + fun failedOutgoingMmsStillNotifiesTheFailure() { + dataModel.isConversationListScrolledToNewestConversation = true + + sync(mmsToAdd = listOf(mms(isRead = true, messageBox = Mms.MESSAGE_BOX_OUTBOX))) + + assertEquals(1, countFailureNotificationCandidates()) + } + + private fun sync( + smsToAdd: List = emptyList(), + mmsToAdd: List = emptyList(), + ) { + val cache = mockk(relaxed = true) + every { cache.getOrCreateConversation(any(), any(), any(), any()) } returns conversationId + val batch = SyncMessageBatch(ArrayList(smsToAdd), ArrayList(mmsToAdd), ArrayList(), cache) + + // The test DataModel hands out its database only off the main thread. The participant id + // cache is static and would point at rows in an earlier test's database. + var failure: Throwable? = null + val worker = Thread { + runCatching { + BugleDatabaseOperations.clearParticipantIdCache() + batch.updateLocalDatabase() + }.onFailure { error -> failure = error } + } + worker.start() + worker.join() + failure?.let { error -> throw error } + } + + private fun sms( + isRead: Boolean, + type: Int = Sms.MESSAGE_TYPE_INBOX, + ): DatabaseMessages.SmsMessage { + val cursor = telephonyCursor( + projection = DatabaseMessages.SmsMessage.getProjection(), + values = mapOf( + Sms._ID to TELEPHONY_ROW_ID, + Sms.TYPE to type, + Sms.ADDRESS to SENDER, + Sms.BODY to "Imported", + Sms.DATE to TIMESTAMP_MILLIS, + Sms.THREAD_ID to THREAD_ID, + Sms.READ to isRead.toInt(), + Sms.SEEN to 0, + Sms.SUBSCRIPTION_ID to ParticipantData.DEFAULT_SELF_SUB_ID, + ), + ) + return cursor.use { DatabaseMessages.SmsMessage.get(it) } + } + + private fun mms( + isRead: Boolean, + messageBox: Int = Mms.MESSAGE_BOX_INBOX, + ): DatabaseMessages.MmsMessage { + val messageType = when (messageBox) { + Mms.MESSAGE_BOX_INBOX -> PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF + else -> PduHeaders.MESSAGE_TYPE_SEND_REQ + } + val cursor = telephonyCursor( + projection = DatabaseMessages.MmsMessage.getProjection(), + values = mapOf( + Mms._ID to TELEPHONY_ROW_ID, + Mms.MESSAGE_BOX to messageBox, + Mms.DATE to TIMESTAMP_MILLIS / 1_000L, + Mms.THREAD_ID to THREAD_ID, + Mms.READ to isRead.toInt(), + Mms.SEEN to 0, + Mms.MESSAGE_TYPE to messageType, + Mms.SUBSCRIPTION_ID to ParticipantData.DEFAULT_SELF_SUB_ID, + ), + ) + return cursor.use { DatabaseMessages.MmsMessage.get(it) }.apply { setSender(SENDER) } + } + + private fun telephonyCursor(projection: Array, values: Map): Cursor { + return MatrixCursor(projection).apply { + addRow(projection.map { column -> values[column] }) + moveToFirst() + } + } + + private fun readImportedSeen(): Boolean { + return database.query( + DatabaseHelper.MESSAGES_TABLE, + arrayOf(MessageColumns.SEEN), + null, + null, + null, + null, + null, + ).use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + cursor.getInt(0) == 1 + } + } + + private fun countFailureNotificationCandidates(): Int { + return database.query( + DatabaseHelper.MESSAGES_TABLE, + arrayOf(MessageColumns.SEEN), + FailedMessageQuery.FAILED_MESSAGES_WHERE_CLAUSE, + null, + null, + null, + null, + ).use { cursor -> cursor.count } + } + + private fun Boolean.toInt(): Int { + return when { + this -> 1 + else -> 0 + } + } + + private companion object { + private const val TELEPHONY_ROW_ID = 324L + private const val THREAD_ID = 3_240L + private const val SENDER = "+15553240000" + private const val TIMESTAMP_MILLIS = 1_780_920_000_000L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBarTest.kt index d60b240e5..ba64c8724 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBarTest.kt @@ -47,7 +47,17 @@ internal class ConversationListSelectionTopAppBarTest { assertEquals(listOf(Action.MarkUnreadClicked), emittedActions) } - private fun setSelectionContent(allSelectedAreRead: Boolean) { + @Test + fun overflowOffersToSelectAll() { + setSelectionContent(allSelectedAreRead = null) + + openOverflowMenu() + composeTestRule.onNodeWithText(string(android.R.string.selectAll)).performClick() + + assertEquals(listOf(Action.SelectAllClicked), emittedActions) + } + + private fun setSelectionContent(allSelectedAreRead: Boolean?) { composeTestRule.setContent { AppTheme { ConversationListSelectionTopAppBar( 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 581b66377..3a92622b3 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 @@ -172,6 +172,23 @@ class ConversationListViewModelTest { } } + @Test + fun selectAllClicked_selectsEveryConversationInSnapshot() { + snapshotFlow.value = snapshotOf( + conversationItem(ConversationId("a")), + conversationItem(ConversationId("b")), + ) + + val viewModel = createViewModel() + viewModel.onAction(Action.SelectAllClicked) + + verify { + selectionDelegate.selectAll( + listOf(ConversationId("a"), ConversationId("b")), + ) + } + } + @Test fun pinAnimationPrepared_commitsPinChangeAndClearsSelection() { runTest(context = mainDispatcherRule.testDispatcher) { @@ -284,6 +301,7 @@ class ConversationListViewModelTest { every { selectionDelegate.selectedIds } returns selectedIdsFlow every { selectionDelegate.bind(any(), any()) } just runs + every { selectionDelegate.selectAll(any()) } just runs every { selectionDelegate.toggle(any()) } just runs every { selectionDelegate.clear() } just runs diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt index 0b5ac7f6c..a80f949d3 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegateImplTest.kt @@ -45,6 +45,22 @@ class ConversationListSelectionDelegateImplTest { assertTrue(delegate.selectedIds.value.isEmpty()) } + @Test + fun selectAll_replacesSelectionAndDropsBlankIdsAndDuplicates() { + delegate.toggle(ConversationId("old")) + + delegate.selectAll( + conversationIds = listOf( + ConversationId("a"), + ConversationId(" "), + ConversationId("b"), + ConversationId("a"), + ), + ) + + assertEquals(listOf("a", "b"), delegate.selectedIds.value.map { it.value }) + } + @Test fun bind_dropsSelectionForConversationsMissingFromSnapshot() = runTest { val snapshot = MutableStateFlow(snapshotOfIds("a", "b")) diff --git a/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt b/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt index 72f6095ff..a94fbb135 100644 --- a/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt +++ b/src/com/android/messaging/data/conversationlist/store/ConversationListStatusStore.kt @@ -2,6 +2,7 @@ package com.android.messaging.data.conversationlist.store import com.android.messaging.data.secondaryuser.SecondaryUserNotifier import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.action.MarkAsSeenAction import javax.inject.Inject internal interface ConversationListStatusStore { @@ -23,6 +24,8 @@ internal class ConversationListStatusStoreImpl @Inject constructor( dataModel.isConversationListScrolledToNewestConversation = isVisible if (isVisible) { + // False keeps posted notifications because bubbles live on them. + MarkAsSeenAction.markAllAsSeen(false) secondaryUserNotifier.cancel() } } diff --git a/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java index be5c85ac6..2ffe27ab0 100644 --- a/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java +++ b/src/com/android/messaging/datamodel/action/MarkAsSeenAction.java @@ -29,6 +29,7 @@ import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.util.LogUtil; import com.android.messaging.util.PendingIntentConstants; +import com.google.common.annotations.VisibleForTesting; /** * Action used to mark all messages as seen @@ -36,13 +37,20 @@ public class MarkAsSeenAction extends Action implements Parcelable { private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_CANCEL_NOTIFICATIONS = "cancel_notifications"; /** * Mark all messages as seen. */ public static void markAllAsSeen() { - final MarkAsSeenAction action = new MarkAsSeenAction((String) null/*conversationId*/); - action.start(); + markAllAsSeen(true); + } + + /** + * Mark all messages as seen, optionally cancelling their notifications. + */ + public static void markAllAsSeen(final boolean cancelNotifications) { + new MarkAsSeenAction((String) null, cancelNotifications).start(); } /** @@ -59,13 +67,21 @@ public static void markAsSeen(final String conversationId) { * messages as seen */ public MarkAsSeenAction(final String conversationId) { + this(conversationId, true); + } + + @VisibleForTesting + MarkAsSeenAction(final String conversationId, final boolean cancelNotifications) { actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putBoolean(KEY_CANCEL_NOTIFICATIONS, cancelNotifications); } @Override protected Object executeAction() { final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final boolean cancelNotifications = + actionParameters.getBoolean(KEY_CANCEL_NOTIFICATIONS, true); final boolean hasSpecificConversation = !TextUtils.isEmpty(conversationId); // Everything in telephony should already have the seen bit set. @@ -75,13 +91,14 @@ protected Object executeAction() { // Now mark the messages as seen in the bugle db final DatabaseWrapper db = DataModel.get().getDatabase(); db.beginTransaction(); + final int count; try { final ContentValues values = new ContentValues(); values.put(MessageColumns.SEEN, 1); if (hasSpecificConversation) { - final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values, + count = db.update(DatabaseHelper.MESSAGES_TABLE, values, MessageColumns.SEEN + " != 1 AND " + MessageColumns.CONVERSATION_ID + "=?", new String[] { conversationId }); @@ -89,7 +106,7 @@ protected Object executeAction() { MessagingContentProvider.notifyMessagesChanged(conversationId); } } else { - db.update(DatabaseHelper.MESSAGES_TABLE, values, + count = db.update(DatabaseHelper.MESSAGES_TABLE, values, MessageColumns.SEEN + " != 1", null/*selectionArgs*/); } @@ -97,9 +114,14 @@ protected Object executeAction() { } finally { db.endTransaction(); } - // After marking messages as seen, update the notifications. This will - // clear the now stale notifications. - BugleNotifications.cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, conversationId); + if (cancelNotifications) { + // After marking messages as seen, update the notifications. This will + // clear the now stale notifications. + BugleNotifications.cancel(PendingIntentConstants.SMS_NOTIFICATION_ID, conversationId); + } else if (count > 0) { + // Keep posted notifications because bubbles live on them. + BugleNotifications.update(BugleNotifications.UPDATE_MESSAGES); + } return null; } diff --git a/src/com/android/messaging/datamodel/action/SyncMessageBatch.java b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java index e7eb81b68..2e92f75e5 100644 --- a/src/com/android/messaging/datamodel/action/SyncMessageBatch.java +++ b/src/com/android/messaging/datamodel/action/SyncMessageBatch.java @@ -173,7 +173,7 @@ private void storeSms(final DatabaseWrapper db, final SmsMessage sms) { selfId, conversationId, bugleStatus, - sms.mSeen, + isSeen(sms.mSeen, sms.mRead, isOutgoing, conversationId), sms.mRead, sms.mTimestampSentInMillis, sms.mTimestampInMillis, @@ -197,6 +197,23 @@ private void storeSms(final DatabaseWrapper db, final SmsMessage sms) { mConversationsToUpdate.add(conversationId); } + /** + * Another default SMS app may mark messages read without ever setting seen, and imported + * history would then notify as new. If you have read it you have seen it; and like a + * received message, one synced while the user can observe it is seen too. Outgoing rows keep + * their flag: the provider marks every non-inbox row read, which says nothing about whether + * the user saw a send failure. + */ + private static boolean isSeen(final boolean seen, final boolean read, + final boolean isOutgoing, final String conversationId) { + if (seen) { + return true; + } + + final boolean isObservable = DataModel.get().isNewMessageObservable(conversationId); + return !isOutgoing && (read || isObservable); + } + public static int bugleStatusForSms(final boolean isOutgoing, final int type, final int status) { int bugleStatus = MessageData.BUGLE_STATUS_UNKNOWN; @@ -275,6 +292,7 @@ private void storeMms(final DatabaseWrapper db, final MmsMessage mms) { // TODO: Need to set correct status on message final MessageData message = MmsUtils.createMmsMessage(mms, conversationId, participantId, selfId, bugleStatus); + message.setMessageSeen(isSeen(mms.mSeen, mms.mRead, isOutgoing, conversationId)); // Inserting mms content into messages table try { diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBar.kt index 56e8d2c59..5133f153c 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBar.kt @@ -137,6 +137,14 @@ private fun SelectionOverflowMenu( onAction: (Action) -> Unit, ) { OverflowMenu { dismiss -> + OverflowMenuItem( + labelResId = android.R.string.selectAll, + onClick = { + onAction(Action.SelectAllClicked) + dismiss() + }, + ) + actions.allSelectedAreRead?.let { areRead -> OverflowMenuItem( labelResId = when { diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index 05c067be4..c73f09363 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -460,6 +460,15 @@ internal class ConversationListViewModel @Inject constructor( onPinClick(isPinned = true) } + is Action.SelectAllClicked -> { + selectionDelegate.selectAll( + conversationIds = snapshot.value + ?.items + .orEmpty() + .map(ConversationListItem::conversationId), + ) + } + is Action.UnpinClicked -> { onPinClick(isPinned = false) } diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt index 8cc0693ad..59185338e 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt @@ -105,6 +105,7 @@ internal sealed interface ConversationListAction { data object MarkReadClicked : SelectionAction data object MarkUnreadClicked : SelectionAction data object PinClicked : SelectionAction + data object SelectAllClicked : SelectionAction data object SelectionCleared : SelectionAction data object UnpinClicked : SelectionAction data object UnsnoozeClicked : SelectionAction diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt index ca812f57a..935b50da2 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListSelectionDelegate.kt @@ -7,6 +7,7 @@ import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,6 +21,7 @@ internal interface ConversationListSelectionDelegate { val selectedIds: StateFlow> fun bind(scope: CoroutineScope, snapshot: StateFlow) + fun selectAll(conversationIds: List) fun toggle(conversationId: ConversationId) fun clear() } @@ -77,6 +79,13 @@ internal class ConversationListSelectionDelegateImpl @Inject constructor() : } } + override fun selectAll(conversationIds: List) { + _selectedIds.value = conversationIds + .filterNot(ConversationId::isBlank) + .distinct() + .toPersistentList() + } + override fun clear() { _selectedIds.value = persistentListOf() }