Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
7ad81ed
Add common components ParticipantAvatar and TwoLineListItem
m4pl May 31, 2026
4652ae4
Add conversation list data repository
m4pl Jun 9, 2026
6eaa175
Add conversation list redesign state and actions
m4pl Jun 10, 2026
d893db7
Map conversation list message status in data layer
m4pl Jun 10, 2026
eb8f23d
Unify conversation archive, delete and block use cases
m4pl Jun 10, 2026
c70ccd3
Extract conversation list delegates
m4pl Jun 10, 2026
5f9c97e
Add conversation list item components
m4pl Jun 10, 2026
cff0758
Add conversation list screen scaffold
m4pl Jun 10, 2026
e7a27c8
Decouple debug options menu from FragmentActivity
m4pl Jun 10, 2026
ff1ee9d
Switch conversation list to Compose
m4pl Jun 10, 2026
d91c81e
Migrate BlockedParticipantsDelegate to repository.setDestinationBlocked
m4pl Jun 16, 2026
ddab8cf
Polish conversation list FABs, toolbar and corners
m4pl Jun 18, 2026
fa4982e
Extract shared primary button and snackbar helpers
m4pl Jun 18, 2026
a194a3e
Move ConversationListItemAvatar to separate file
m4pl Jun 19, 2026
6ca3e30
Add avatar quick actions to conversation list
m4pl Jun 19, 2026
001c9e2
Add chat snooze and polish conversation list rows
m4pl Jun 20, 2026
4842eba
Add swipe actions to conversation list rows
m4pl Jun 20, 2026
4b2dfc3
Rework conversation list selection toolbar
m4pl Jun 21, 2026
4ae6e40
Add pin conversation support to the conversation list
m4pl Jun 21, 2026
7bf0380
Add tests for pin conversation support
m4pl Jun 21, 2026
9094594
Gate Compose activities behind permission check
m4pl Jun 21, 2026
f197e12
Make conversation list animations smooth with optimistic state
m4pl Jun 22, 2026
adc64b4
Polish conversation list statuses, avatar actions and snackbars
m4pl Jun 23, 2026
693436e
Remove conversation list redesign package nesting
m4pl Jun 23, 2026
80627f4
Fix conversation list interactions and clean up supporting APIs
m4pl Jun 24, 2026
02daa41
Simplify conversation list selection and action state model
m4pl Jun 24, 2026
b7e4fa1
Restore MMS subject cleansing and clarify conversation list mapper na…
m4pl Jun 24, 2026
1bf12b8
Refactor conversation list optimistic updates and list animations
m4pl Jun 24, 2026
5ff7ae3
Update optimistic conversation list reducer coverage
m4pl Jun 24, 2026
eee3603
Cover conversation stores and permission gate
m4pl Jun 24, 2026
a1add22
Cover conversation list reorder behavior
m4pl Jun 24, 2026
7752d52
Cover conversation list delegate
m4pl Jun 24, 2026
8dee207
Cover conversation list state mapping and actions
m4pl Jun 24, 2026
271f70a
Lock conversation swipe to horizontal dominant gestures
m4pl Jun 25, 2026
da2613b
Stabilize conversation list appearance and insertion animations
m4pl Jun 25, 2026
6668b9c
Tidy conversation list empty state and FABs
m4pl Jun 25, 2026
b9681c6
Fix compilation after rebase
m4pl Jun 26, 2026
000a06c
Share conversation list fixtures and update UI tests
m4pl Jun 26, 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