Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
88c9da8
Add common components ParticipantAvatar and TwoLineListItem
m4pl May 31, 2026
6021dd6
Add conversation list data repository
m4pl Jun 9, 2026
ecb9328
Add conversation list redesign state and actions
m4pl Jun 10, 2026
9689aac
Map conversation list message status in data layer
m4pl Jun 10, 2026
f15711d
Unify conversation archive, delete and block use cases
m4pl Jun 10, 2026
ef00719
Extract conversation list delegates
m4pl Jun 10, 2026
113936a
Add conversation list item components
m4pl Jun 10, 2026
eb0fb26
Add conversation list screen scaffold
m4pl Jun 10, 2026
0f4b2bc
Decouple debug options menu from FragmentActivity
m4pl Jun 10, 2026
f6bcc66
Switch conversation list to Compose
m4pl Jun 10, 2026
4443283
Migrate BlockedParticipantsDelegate to repository.setDestinationBlocked
m4pl Jun 16, 2026
099182b
Polish conversation list FABs, toolbar and corners
m4pl Jun 18, 2026
7e64bec
Extract shared primary button and snackbar helpers
m4pl Jun 18, 2026
bb48b90
Move ConversationListItemAvatar to separate file
m4pl Jun 19, 2026
23ec0b5
Add avatar quick actions to conversation list
m4pl Jun 19, 2026
b453fdd
Add chat snooze and polish conversation list rows
m4pl Jun 20, 2026
404909a
Add swipe actions to conversation list rows
m4pl Jun 20, 2026
7d25fa7
Rework conversation list selection toolbar
m4pl Jun 21, 2026
55e6c76
Add pin conversation support to the conversation list
m4pl Jun 21, 2026
a7de61c
Add tests for pin conversation support
m4pl Jun 21, 2026
77c9110
Gate Compose activities behind permission check
m4pl Jun 21, 2026
46ab680
Make conversation list animations smooth with optimistic state
m4pl Jun 22, 2026
0d0aadd
Polish conversation list statuses, avatar actions and snackbars
m4pl Jun 23, 2026
b5bea57
Remove conversation list redesign package nesting
m4pl Jun 23, 2026
a3bb6bf
Fix conversation list interactions and clean up supporting APIs
m4pl Jun 24, 2026
3472732
Simplify conversation list selection and action state model
m4pl Jun 24, 2026
8ea23f5
Restore MMS subject cleansing and clarify conversation list mapper na…
m4pl Jun 24, 2026
daf4daf
Refactor conversation list optimistic updates and list animations
m4pl Jun 24, 2026
9655876
Update optimistic conversation list reducer coverage
m4pl Jun 24, 2026
6019b62
Cover conversation stores and permission gate
m4pl Jun 24, 2026
270dfaa
Cover conversation list reorder behavior
m4pl Jun 24, 2026
93a18f7
Cover conversation list delegate
m4pl Jun 24, 2026
a6e7f73
Cover conversation list state mapping and actions
m4pl Jun 24, 2026
941f605
Lock conversation swipe to horizontal dominant gestures
m4pl Jun 25, 2026
5bd84f0
Stabilize conversation list appearance and insertion animations
m4pl Jun 25, 2026
47dda70
Tidy conversation list empty state and FABs
m4pl Jun 25, 2026
674ecbb
Fix compilation after rebase
m4pl Jun 26, 2026
85d3177
Share conversation list fixtures and update UI tests
m4pl Jun 26, 2026
a065609
Use BugleComponentActivity for forward messages
m4pl Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ internal fun participant(
displayName: String = DISPLAY_NAME_1,
destination: String? = DESTINATION_1,
details: String? = destination,
canShowContact: Boolean = true,
): BlockedParticipantUiState {
return BlockedParticipantUiState(
participantId = participantId,
Expand All @@ -90,6 +91,7 @@ internal fun participant(
lookupKey = null,
normalizedDestination = destination,
canCall = false,
canShowContact = canShowContact,
isContactSaved = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,16 @@ import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithTag
import androidx.recyclerview.widget.RecyclerView
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.common.test.rules.AppTestRule
import com.android.common.test.rules.MessagingTestRule
import com.android.messaging.R
import com.android.messaging.ui.conversationlist.ConversationListActivity
import com.android.messaging.ui.conversationlist.ui.CONVERSATION_LIST_TEST_TAG
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -42,19 +38,20 @@ class ConversationUserFlowTest {
)

scenario.use {
onView(withId(android.R.id.list))
.check(matches(isDisplayed()))
composeRule.waitUntilAtLeastOneExists(
matcher = hasTestTag(testTag = CONVERSATION_LIST_TEST_TAG),
timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS,
)

onView(withId(R.id.start_new_conversation_button))
.check(matches(isDisplayed()))
composeRule
.onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG)
.assertIsDisplayed()

onView(withId(android.R.id.list))
.perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
0,
click(),
),
)
composeRule
.onNodeWithTag(testTag = CONVERSATION_LIST_TEST_TAG)
.onChildren()
.onFirst()
.performClick()

composeRule.waitUntilAtLeastOneExists(
matcher = hasTestTag(testTag = CONVERSATION_TEXT_FIELD_TEST_TAG),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.android.messaging.data.conversation.store

import com.android.messaging.datamodel.BugleDatabaseOperations
import com.android.messaging.datamodel.DataModel
import com.android.messaging.datamodel.DatabaseWrapper
import com.android.messaging.datamodel.MessagingContentProvider
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 io.mockk.verifyOrder
import org.junit.After
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class ConversationPinStoreTest {

private val databaseWrapper = mockk<DatabaseWrapper>(relaxed = true)
private val dataModel = mockk<DataModel>()

private val store = ConversationPinStoreImpl()

@Before
fun setUp() {
mockkStatic(DataModel::class)
mockkStatic(BugleDatabaseOperations::class)
mockkStatic(MessagingContentProvider::class)

every { DataModel.get() } returns dataModel
every { dataModel.database } returns databaseWrapper
every {
BugleDatabaseOperations.updateConversationPinStatusInTransaction(any(), any(), any())
} just runs
every { MessagingContentProvider.notifyConversationListChanged() } just runs
every { MessagingContentProvider.notifyConversationMetadataChanged(any()) } just runs
}

@After
fun tearDown() {
unmockkAll()
}

@Test
fun pinConversation_updatesPinStatusToTrueInsideTransactionAndNotifies() {
store.pinConversation(CONVERSATION_ID)

verifyOrder {
databaseWrapper.beginTransaction()
BugleDatabaseOperations.updateConversationPinStatusInTransaction(
databaseWrapper,
CONVERSATION_ID,
true,
)
databaseWrapper.setTransactionSuccessful()
databaseWrapper.endTransaction()
}
verify(exactly = 1) {
MessagingContentProvider.notifyConversationListChanged()
}
verify(exactly = 1) {
MessagingContentProvider.notifyConversationMetadataChanged(CONVERSATION_ID)
}
}

@Test
fun unpinConversation_updatesPinStatusToFalse() {
store.unpinConversation(conversationId = CONVERSATION_ID)

verify(exactly = 1) {
BugleDatabaseOperations.updateConversationPinStatusInTransaction(
databaseWrapper,
CONVERSATION_ID,
false,
)
}
}

@Test
fun pinConversation_updateFails_endsTransactionWithoutNotifying() {
val failure = IllegalStateException("update failed")
every {
BugleDatabaseOperations.updateConversationPinStatusInTransaction(any(), any(), any())
} throws failure

val thrown = assertThrows(IllegalStateException::class.java) {
store.pinConversation(CONVERSATION_ID)
}

assertSame(failure, thrown)
verify(exactly = 1) { databaseWrapper.endTransaction() }
verify(exactly = 0) { databaseWrapper.setTransactionSuccessful() }
verify(exactly = 0) { MessagingContentProvider.notifyConversationListChanged() }
verify(exactly = 0) {
MessagingContentProvider.notifyConversationMetadataChanged(any())
}
}

private companion object {
private const val CONVERSATION_ID = "conversation-42"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package com.android.messaging.data.conversation.store

import android.content.ContentValues
import android.database.sqlite.SQLiteStatement
import com.android.messaging.datamodel.BugleDatabaseOperations
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.MessagingContentProvider
import com.android.messaging.sms.MmsUtils
import com.android.messaging.util.PendingIntentConstants
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class ConversationReadStoreTest {

private val database = mockk<DatabaseWrapper>(relaxed = true)
private val dataModel = mockk<DataModel>()
private val latestMessageStatement = mockk<SQLiteStatement>()

private val store = ConversationReadStoreImpl()

@Before
fun setUp() {
mockkStatic(DataModel::class)
mockkStatic(BugleDatabaseOperations::class)
mockkStatic(BugleNotifications::class)
mockkStatic(MessagingContentProvider::class)
mockkStatic(MmsUtils::class)

every { DataModel.get() } returns dataModel
every { dataModel.database } returns database
every { BugleDatabaseOperations.getThreadId(any(), any()) } returns THREAD_ID
every {
BugleDatabaseOperations.getQueryConversationsLatestMessageStatement(any(), any())
} returns latestMessageStatement
every { latestMessageStatement.simpleQueryForString() } returns MESSAGE_ID
every { database.update(any(), any(), any(), any()) } returns 1
every { MmsUtils.updateSmsReadStatus(any(), any()) } just runs
every { MessagingContentProvider.notifyMessagesChanged(any()) } just runs
every { BugleNotifications.cancel(any(), any()) } just runs
}

@After
fun tearDown() {
unmockkAll()
}

@Test
fun markConversationRead_updatesMessagesAndCancelsNotification() {
val values = slot<ContentValues>()

store.markConversationRead(CONVERSATION_ID)

verify { MmsUtils.updateSmsReadStatus(THREAD_ID, Long.MAX_VALUE) }
verify {
database.update(
DatabaseHelper.MESSAGES_TABLE,
capture(values),
any(),
match { arguments -> arguments.contentEquals(arrayOf(CONVERSATION_ID)) },
)
}

assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ))
assertEquals(1, values.captured.getAsInteger(DatabaseHelper.MessageColumns.SEEN))

verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) }
verify {
BugleNotifications.cancel(
PendingIntentConstants.SMS_NOTIFICATION_ID,
CONVERSATION_ID,
)
}
}

@Test
fun markConversationRead_nothingUpdated_skipsTelephonyAndContentNotifications() {
every { BugleDatabaseOperations.getThreadId(any(), any()) } returns -1L
every { database.update(any(), any(), any(), any()) } returns 0

store.markConversationRead(CONVERSATION_ID)

verify(exactly = 0) { MmsUtils.updateSmsReadStatus(any(), any()) }
verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) }
verify {
BugleNotifications.cancel(
PendingIntentConstants.SMS_NOTIFICATION_ID,
CONVERSATION_ID,
)
}
}

@Test
fun markConversationUnread_updatesLatestMessage() {
val values = slot<ContentValues>()

store.markConversationUnread(CONVERSATION_ID)

verify {
BugleDatabaseOperations.getQueryConversationsLatestMessageStatement(
database,
CONVERSATION_ID,
)
}
verify {
database.update(
DatabaseHelper.MESSAGES_TABLE,
capture(values),
any(),
match { arguments -> arguments.contentEquals(arrayOf(MESSAGE_ID)) },
)
}
assertEquals(0, values.captured.getAsInteger(DatabaseHelper.MessageColumns.READ))
verify { MessagingContentProvider.notifyMessagesChanged(CONVERSATION_ID) }
}

@Test
fun markConversationUnread_noLatestMessage_doesNothing() {
every { latestMessageStatement.simpleQueryForString() } returns null

store.markConversationUnread(CONVERSATION_ID)

verify(exactly = 0) { database.update(any(), any(), any(), any()) }
verify(exactly = 0) { MessagingContentProvider.notifyMessagesChanged(any()) }
}

private companion object {
private const val CONVERSATION_ID = "conversation-42"
private const val MESSAGE_ID = "message-24"
private const val THREAD_ID = 7L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.android.messaging.data.conversationlist.store

import com.android.messaging.datamodel.DataModel
import com.android.messaging.datamodel.SyncManager
import com.android.messaging.receiver.SmsReceiver
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
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test

internal class ConversationListStatusStoreTest {

private val dataModel = mockk<DataModel>(relaxed = true)
private val syncManager = mockk<SyncManager>()

private val store = ConversationListStatusStoreImpl()

@Before
fun setUp() {
mockkStatic(DataModel::class)
mockkStatic(SmsReceiver::class)

every { DataModel.get() } returns dataModel
every { dataModel.syncManager } returns syncManager
every { SmsReceiver.cancelSecondaryUserNotification() } just runs
}

@After
fun tearDown() {
unmockkAll()
}

@Test
fun hasFirstSyncCompleted_returnsSyncManagerValue() {
every { syncManager.hasFirstSyncCompleted } returns true

assertTrue(store.hasFirstSyncCompleted())
}

@Test
fun setNewestConversationVisible_visible_updatesStatusAndCancelsSecondaryNotification() {
store.setNewestConversationVisible(isVisible = true)

verify { dataModel.isConversationListScrolledToNewestConversation = true }
verify { SmsReceiver.cancelSecondaryUserNotification() }
}

@Test
fun setNewestConversationVisible_notVisible_updatesStatusWithoutCancellingNotification() {
store.setNewestConversationVisible(isVisible = false)

verify(exactly = 1) {
dataModel.isConversationListScrolledToNewestConversation = false
}
verify(exactly = 0) {
SmsReceiver.cancelSecondaryUserNotification()
}
}
}
Loading