diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 090c1d900..5a85e4d3b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -135,7 +135,7 @@ jobs:
if-no-files-found: ignore
instrumented-tests:
- name: Instrumented tests (Android 16 x86_64)
+ name: Instrumented tests (Android 17 x86_64)
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -152,12 +152,29 @@ jobs:
java-version: 17
cache: gradle
+ - name: Build app and instrumented test APKs
+ run: >
+ ./gradlew :app:assembleDebug :app:assembleDebugAndroidTest
+ --no-daemon --stacktrace --console=plain
+
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
+ - name: Update Android command line tools
+ id: cmdline-tools
+ run: |
+ SDK="${ANDROID_HOME:-/usr/local/lib/android/sdk}"
+ yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --install 'cmdline-tools;latest' > /dev/null
+ if [ -d "$SDK/cmdline-tools/latest-2" ]; then
+ rm -rf "$SDK/cmdline-tools/latest"
+ mv "$SDK/cmdline-tools/latest-2" "$SDK/cmdline-tools/latest"
+ fi
+ version=$("$SDK/cmdline-tools/latest/bin/sdkmanager" --version 2>/dev/null | grep -Eo '^[0-9][0-9.]*' | head -1)
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
- name: Restore AVD cache
id: avd-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -165,15 +182,19 @@ jobs:
path: |
~/.android/avd/*
~/.android/adb*
- key: avd-36-x86_64-default
+ key: avd-37.0-x86_64-google_apis-${{ steps.cmdline-tools.outputs.version }}
- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
- api-level: 36
+ api-level: "37.0"
arch: x86_64
- target: default
+ target: google_apis
+ cores: 4
+ ram-size: 4096M
+ heap-size: 576M
+ disk-size: 8G
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
@@ -182,18 +203,22 @@ jobs:
- name: Run instrumented tests and coverage gate
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
- api-level: 36
+ api-level: "37.0"
arch: x86_64
- target: default
+ target: google_apis
+ cores: 4
+ ram-size: 4096M
+ heap-size: 576M
+ disk-size: 8G
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
- script: >
- ./gradlew :app:connectedDebugAndroidTest :app:jacocoAndroidTestVerification
- -PandroidTestCoverage=true
- -PandroidTestMinCoverage=80
- -PandroidTestMinBranchCoverage=50
- --no-daemon --stacktrace --console=plain
+ script: |
+ adb logcat -G 16M
+ ./gradlew :app:connectedDebugAndroidTest :app:jacocoAndroidTestVerification -PandroidTestCoverage=true -PandroidTestMinCoverage=80 -PandroidTestMinBranchCoverage=50 --no-daemon --stacktrace --console=plain
+ rc=$?
+ adb logcat -d > logcat.txt
+ exit $rc
- name: Upload instrumented test reports
if: failure()
@@ -205,4 +230,5 @@ jobs:
app/build/outputs/androidTest-results/connected/
app/build/reports/jacoco/jacocoAndroidTestReport/
app/build/reports/androidTests/connected/
+ logcat.txt
if-no-files-found: ignore
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index d9daeacee..6fda90c3d 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -35,6 +35,9 @@
+
+
@@ -235,10 +238,10 @@
android:grantUriPermissions="true"
android:exported="false" />
-
+
+ val bitmap = Bitmap.createBitmap(IMAGE_WIDTH, IMAGE_HEIGHT, Bitmap.Config.ARGB_8888)
+ bitmap.eraseColor(Color.BLUE)
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 100, checkNotNull(out))
+ }
+ val attachmentFile = checkNotNull(MediaScratchFileProvider.getFileFromUri(attachmentUri))
+ ExifInterface(attachmentFile.absolutePath).apply {
+ setAttribute(ExifInterface.TAG_GPS_LATITUDE, "37/1,25/1,19/1")
+ setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, "N")
+ setAttribute(ExifInterface.TAG_GPS_LONGITUDE, "122/1,5/1,4/1")
+ setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, "W")
+ setAttribute(
+ ExifInterface.TAG_ORIENTATION,
+ ExifInterface.ORIENTATION_ROTATE_90.toString(),
+ )
+ saveAttributes()
+ }
+
+ context.getSystemService(NotificationManager::class.java).createNotificationChannel(
+ NotificationChannel(
+ NotificationChannelUtil.INCOMING_MESSAGES,
+ "Conversations",
+ NotificationManager.IMPORTANCE_HIGH,
+ ),
+ )
+ InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
+ context.packageName,
+ Manifest.permission.POST_NOTIFICATIONS,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ context.contentResolver.delete(attachmentUri, null, null)
+ NotificationImageProvider.listImageFiles().forEach { it.delete() }
+ NotificationManagerCompat.from(context).cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
+ }
+
+ @Test
+ fun createStyledMessage_withImageAttachment_attachesAnOpenableUri() {
+ val message = messageLineInfo(attachmentUri).createStyledMessage(SENDER)
+
+ val dataUri = message.dataUri
+ assertNotNull("no image was attached to the notification", dataUri)
+
+ val bytes = try {
+ context.contentResolver.openInputStream(checkNotNull(dataUri)).use {
+ checkNotNull(it).readBytes()
+ }
+ } catch (e: FileNotFoundException) {
+ throw AssertionError(
+ "BUG-015: the notification carries $dataUri, which cannot be opened, so the " +
+ "image never renders in the shade",
+ e,
+ )
+ }
+
+ assertTrue("the notification image $dataUri opened but is empty", bytes.isNotEmpty())
+ }
+
+ @Test
+ fun createStyledMessage_withImageAttachment_stripsTheSendersMetadata() {
+ assertNotNull(
+ "the attachment was seeded without the gps metadata this test looks for",
+ exifOf(attachmentUri).getAttribute(ExifInterface.TAG_GPS_LATITUDE),
+ )
+
+ val dataUri =
+ checkNotNull(messageLineInfo(attachmentUri).createStyledMessage(SENDER).dataUri)
+
+ assertNotEquals("the sender's own file was attached", attachmentUri, dataUri)
+ assertTrue(
+ "no image was attached, so the metadata check below proves nothing",
+ boundsOf(dataUri).outWidth > 0,
+ )
+ assertNull(
+ "the notification image still carries the sender's gps metadata",
+ exifOf(dataUri).getAttribute(ExifInterface.TAG_GPS_LATITUDE),
+ )
+ }
+
+ @Test
+ fun createStyledMessage_withRotatedImageAttachment_bakesTheOrientationIn() {
+ val dataUri = messageLineInfo(attachmentUri).createStyledMessage(SENDER).dataUri
+
+ val bounds = boundsOf(checkNotNull(dataUri))
+
+ assertEquals("the exif rotation was dropped, not applied", IMAGE_HEIGHT, bounds.outWidth)
+ assertEquals("the exif rotation was dropped, not applied", IMAGE_WIDTH, bounds.outHeight)
+ }
+
+ /**
+ * The platform grant-checks every uri a notification carries and, at targetSdk >= P, rethrows
+ * the [SecurityException] instead of dropping the uri. An image we attach has to survive that.
+ */
+ @Test
+ fun postNotification_withImageAttachment_posts() {
+ val posted = post(messageLineInfo(attachmentUri).createStyledMessage(SENDER))
+
+ assertTrue("the platform refused to grant access to the attached image", posted)
+ }
+
+ /**
+ * ...and an image it will not grant has to be dropped rather than thrown out of `notify()`,
+ * which would otherwise kill the process. `content://call_log` is read-guarded by a permission
+ * we never hold, so it stands in for an mms part we can no longer read after losing the sms
+ * role -- the case [BugleNotifications.updateWithInlineReply] hits when it re-posts a
+ * notification that has been sitting in the shade since before the role changed hands.
+ */
+ @Test
+ fun postNotification_withUngrantableAttachment_dropsItInsteadOfThrowing() {
+ val message = MessagingStyle.Message("Check out this photo!", 1L, SENDER)
+ .setData(ContentType.IMAGE_JPEG, UNGRANTABLE_URI)
+
+ val posted = post(message)
+
+ assertFalse("$UNGRANTABLE_URI was grantable after all, so this proves nothing", posted)
+ }
+
+ @Test
+ fun createStyledMessage_withUndecodableAttachment_leavesNoFileBehind() {
+ val brokenUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(IMAGE_EXTENSION)
+ context.contentResolver.openOutputStream(brokenUri).use {
+ checkNotNull(it).write(NOT_AN_IMAGE.toByteArray())
+ }
+
+ val dataUri = messageLineInfo(brokenUri).createStyledMessage(SENDER).dataUri
+ context.contentResolver.delete(brokenUri, null, null)
+
+ assertNull("an attachment that cannot be decoded was attached anyway", dataUri)
+ assertEquals(
+ "the failed encode left its placeholder behind",
+ 0,
+ NotificationImageProvider.listImageFiles().size,
+ )
+ }
+
+ @Test
+ fun createStyledMessage_calledTwice_doesNotReuseTheSameFile() {
+ val first = messageLineInfo(attachmentUri).createStyledMessage(SENDER).dataUri
+ val second = messageLineInfo(attachmentUri).createStyledMessage(SENDER).dataUri
+
+ assertNotEquals("the same file was handed to two notifications", first, second)
+ }
+
+ /**
+ * The sweep runs after every notification pass, so it has to tell an image the shade is still
+ * showing from one left behind by a notification that has since been replaced or cancelled.
+ */
+ @Test
+ fun sweepNotificationImages_keepsPostedImagesAndDeletesOrphans() {
+ val orphan = imageFileOf(NotificationImageProvider.buildNotificationImageUri())
+ val message = messageLineInfo(attachmentUri).createStyledMessage(SENDER)
+ val posted = imageFileOf(message.dataUri)
+ assertTrue("the platform refused to grant access to the attached image", post(message))
+ awaitPostedNotification()
+ val earlier = System.currentTimeMillis() - PREVIOUS_PASS_AGE_MILLIS
+ assertTrue(orphan.setLastModified(earlier))
+ assertTrue(posted.setLastModified(earlier))
+
+ BugleNotifications.sweepNotificationImages(System.currentTimeMillis())
+
+ assertTrue("the image the shade is showing was swept", posted.exists())
+ assertFalse("the orphaned image survived the sweep", orphan.exists())
+ }
+
+ @Test
+ fun sweepNotificationImages_keepsImagesFromTheCurrentPass() {
+ val passStart = System.currentTimeMillis()
+ val styled = messageLineInfo(attachmentUri).createStyledMessage(SENDER)
+ val image = imageFileOf(styled.dataUri)
+
+ BugleNotifications.sweepNotificationImages(passStart)
+
+ assertTrue(
+ "an image written during the pass was swept before it was posted",
+ image.exists(),
+ )
+ }
+
+ private fun imageFileOf(uri: Uri?): File {
+ return checkNotNull(NotificationImageProvider.getFileFromUri(uri))
+ }
+
+ private fun awaitPostedNotification() {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ val deadline = System.currentTimeMillis() + POST_TIMEOUT_MILLIS
+ while (System.currentTimeMillis() < deadline) {
+ if (manager.activeNotifications.any { it.tag == NOTIFICATION_TAG }) {
+ return
+ }
+ Thread.sleep(POST_POLL_MILLIS)
+ }
+ throw AssertionError("the notification never reached the shade")
+ }
+
+ private fun boundsOf(uri: Uri): BitmapFactory.Options {
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ context.contentResolver.openInputStream(uri).use {
+ BitmapFactory.decodeStream(it, null, bounds)
+ }
+ return bounds
+ }
+
+ private fun exifOf(uri: Uri): ExifInterface {
+ return context.contentResolver.openInputStream(uri).use {
+ ExifInterface(checkNotNull(it))
+ }
+ }
+
+ private fun post(message: MessagingStyle.Message): Boolean {
+ val style = MessagingStyle(SELF).also { it.addMessage(message) }
+ val notification =
+ NotificationCompat.Builder(context, NotificationChannelUtil.INCOMING_MESSAGES)
+ .setSmallIcon(R.drawable.ic_sms_light)
+ .setStyle(style)
+ .build()
+
+ return BugleNotifications.postNotification(
+ NotificationManagerCompat.from(context),
+ NOTIFICATION_TAG,
+ NOTIFICATION_ID,
+ notification,
+ )
+ }
+
+ private fun messageLineInfo(uri: Uri): MessageNotificationState.MessageLineInfo {
+ return MessageNotificationState.MessageLineInfo(
+ // authorId, authorFullName, authorFirstName
+ "author",
+ "Sender",
+ "Sender",
+ // text, attachmentUri, attachmentType
+ "Check out this photo!",
+ uri,
+ ContentType.IMAGE_JPEG,
+ // isManualDownloadNeeded, avatarUri, messageId, timestamp, contactUriString
+ false,
+ null,
+ "1",
+ 1L,
+ null,
+ )
+ }
+
+ private companion object {
+ const val IMAGE_WIDTH = 8
+ const val IMAGE_HEIGHT = 16
+ const val IMAGE_EXTENSION = "jpg"
+ const val NOT_AN_IMAGE = "this is not an image"
+ const val PREVIOUS_PASS_AGE_MILLIS = 60_000L
+ const val POST_TIMEOUT_MILLIS = 5_000L
+ const val POST_POLL_MILLIS = 50L
+ const val NOTIFICATION_ID = 0x7103
+ const val NOTIFICATION_TAG = "BUG-015"
+ val UNGRANTABLE_URI: Uri = Uri.parse("content://call_log/calls/1")
+ val SELF: Person = Person.Builder().setName("Me").build()
+ val SENDER: Person = Person.Builder().setName("Sender").setKey("author").build()
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/android/common/test/helpers/FirstRunFlagsHelper.kt b/app/src/androidTest/kotlin/com/android/common/test/helpers/FirstRunFlagsHelper.kt
new file mode 100644
index 000000000..496cf9513
--- /dev/null
+++ b/app/src/androidTest/kotlin/com/android/common/test/helpers/FirstRunFlagsHelper.kt
@@ -0,0 +1,32 @@
+package com.android.common.test.helpers
+
+import com.android.messaging.util.BuglePrefs
+import com.android.messaging.util.BuglePrefsKeys
+
+/**
+ * Suppresses the one-off prompts a fresh install shows before the app is usable, so tests land on
+ * the screen under test instead of the SMS warning or the self phone number permission dialog.
+ */
+object FirstRunFlagsHelper {
+
+ private val flagDefaults = mapOf(
+ BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED to
+ BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED_DEFAULT,
+ BuglePrefsKeys.SELF_PHONE_NUMBER_PERMISSION_REQUESTED to
+ BuglePrefsKeys.SELF_PHONE_NUMBER_PERMISSION_REQUESTED_DEFAULT,
+ )
+
+ fun suppressFirstRunPrompts(): Map {
+ val prefs = BuglePrefs.getApplicationPrefs()
+
+ return flagDefaults.mapValues { (key, default) ->
+ prefs.getBoolean(key, default).also { prefs.putBoolean(key, true) }
+ }
+ }
+
+ fun restoreFirstRunPrompts(previousFlags: Map) {
+ val prefs = BuglePrefs.getApplicationPrefs()
+
+ previousFlags.forEach { (key, value) -> prefs.putBoolean(key, value) }
+ }
+}
diff --git a/app/src/androidTest/kotlin/com/android/common/test/helpers/SmsWarningHelper.kt b/app/src/androidTest/kotlin/com/android/common/test/helpers/SmsWarningHelper.kt
deleted file mode 100644
index 4980b6292..000000000
--- a/app/src/androidTest/kotlin/com/android/common/test/helpers/SmsWarningHelper.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.android.common.test.helpers
-
-import com.android.messaging.util.BuglePrefs
-import com.android.messaging.util.BuglePrefsKeys
-
-object SmsWarningHelper {
-
- fun acknowledgeSmsWarning(): Boolean {
- val wasAcknowledged = BuglePrefs.getApplicationPrefs().getBoolean(
- BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED,
- BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED_DEFAULT,
- )
- BuglePrefs.getApplicationPrefs().putBoolean(
- BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED,
- true,
- )
-
- return wasAcknowledged
- }
-
- fun restoreSmsWarning(wasAcknowledged: Boolean) {
- BuglePrefs.getApplicationPrefs().putBoolean(
- BuglePrefsKeys.SMS_WARNING_ACKNOWLEDGED,
- wasAcknowledged,
- )
- }
-}
diff --git a/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt b/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt
index de86fb699..5c7593506 100644
--- a/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt
+++ b/app/src/androidTest/kotlin/com/android/common/test/rules/AppTestRule.kt
@@ -1,7 +1,7 @@
package com.android.common.test.rules
+import com.android.common.test.helpers.FirstRunFlagsHelper
import com.android.common.test.helpers.ShellCommandHelper
-import com.android.common.test.helpers.SmsWarningHelper
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
@@ -15,7 +15,7 @@ class AppTestRule : TestRule {
return object : Statement() {
override fun evaluate() {
val previousSmsRoleHolders = ShellCommandHelper.setupSmsDefaultRole()
- val wasSmsWarningAcknowledged = SmsWarningHelper.acknowledgeSmsWarning()
+ val previousFirstRunFlags = FirstRunFlagsHelper.suppressFirstRunPrompts()
var baseFailure: Throwable? = null
try {
base.evaluate()
@@ -23,8 +23,8 @@ class AppTestRule : TestRule {
baseFailure = throwable
} finally {
try {
- SmsWarningHelper.restoreSmsWarning(
- wasAcknowledged = wasSmsWarningAcknowledged,
+ FirstRunFlagsHelper.restoreFirstRunPrompts(
+ previousFlags = previousFirstRunFlags,
)
ShellCommandHelper.restoreSmsDefaultRole(
previousRoleHolders = previousSmsRoleHolders,
diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt
index 633c0241f..66f88efb7 100644
--- a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt
+++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/subscription/ui/SubscriptionSettingsScreenTest.kt
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.android.messaging.R
import com.android.messaging.data.subscription.model.SubId
+import com.android.messaging.ui.appsettings.subscription.model.PhoneNumberDialogUiState
import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsAction as Action
import com.android.messaging.ui.appsettings.subscription.model.SubscriptionUiState
import com.android.messaging.ui.core.AppTheme
@@ -92,7 +93,7 @@ class SubscriptionSettingsScreenTest {
}
@Test
- fun phoneNumberClick_showsDialog() {
+ fun phoneNumberClick_reportsTheClick() {
val sub = createDefaultSubscription(phoneNumber = "+1234567890")
setContent(subscriptionSettings = sub)
@@ -100,6 +101,17 @@ class SubscriptionSettingsScreenTest {
composeTestRule.onNodeWithText(phoneTitle).performClick()
composeTestRule.waitForIdle()
+ verify { onAction(Action.PhoneNumberClicked) }
+ }
+
+ @Test
+ fun visiblePhoneNumberDialogState_showsDialog() {
+ val sub = createDefaultSubscription(phoneNumber = "+1234567890")
+ setContent(
+ subscriptionSettings = sub,
+ phoneNumberDialogState = PhoneNumberDialogUiState(isVisible = true),
+ )
+
val okText = composeTestRule.activity.getString(android.R.string.ok)
composeTestRule.onNodeWithText(okText).assertIsDisplayed()
}
@@ -273,6 +285,7 @@ class SubscriptionSettingsScreenTest {
private fun setContent(
subscriptionSettings: SubscriptionUiState = createDefaultSubscription(),
+ phoneNumberDialogState: PhoneNumberDialogUiState = PhoneNumberDialogUiState(),
) {
composeTestRule.setContent {
AppTheme {
@@ -281,6 +294,7 @@ class SubscriptionSettingsScreenTest {
title = "Advanced Settings",
onAction = onAction,
onNavigateBack = {},
+ phoneNumberDialogState = phoneNumberDialogState,
)
}
}
diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt
index 85e25158b..cb8355922 100644
--- a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt
+++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarLayoutTest.kt
@@ -2,11 +2,14 @@ package com.android.messaging.ui.conversation.composer.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.Density
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol
import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG
@@ -25,37 +28,46 @@ class ConversationComposeBarLayoutTest {
val composeTestRule = createComposeRule()
@Test
- fun singleLineInput_keepsTextFieldAndSendButtonHeightsEqual() {
+ fun singleLineInputAtDefaultFontScale_keepsTextFieldAndSendButtonHeightsEqual() {
composeTestRule.setContent {
- AppTheme {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.BottomCenter,
- ) {
- ConversationComposeBar(
- audioRecording = ConversationAudioRecordingUiState(),
- messageText = "Hello",
- subjectText = "",
- sendProtocol = ConversationDraftSendProtocol.SMS,
- segmentCounter = null,
- isMessageFieldEnabled = true,
- isAttachmentActionEnabled = false,
- isRecordActionEnabled = true,
- isSendActionEnabled = true,
- shouldShowRecordAction = false,
- onContactAttachClick = {},
- onMediaPickerClick = {},
- onLockedAudioRecordingStartRequest = {},
- onMessageTextChange = {},
- onAudioRecordingStartRequest = {},
- onAudioRecordingFinish = {},
- onAudioRecordingLock = { false },
- onAudioRecordingCancel = {},
- onSendClick = {},
- onSendActionLongClick = {},
- onSubjectChipClick = {},
- onSubjectChipClear = {},
- )
+ val density = LocalDensity.current
+
+ CompositionLocalProvider(
+ LocalDensity provides Density(
+ density = density.density,
+ fontScale = DEFAULT_FONT_SCALE,
+ ),
+ ) {
+ AppTheme {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ ConversationComposeBar(
+ audioRecording = ConversationAudioRecordingUiState(),
+ messageText = "Hello",
+ subjectText = "",
+ sendProtocol = ConversationDraftSendProtocol.SMS,
+ segmentCounter = null,
+ isMessageFieldEnabled = true,
+ isAttachmentActionEnabled = false,
+ isRecordActionEnabled = true,
+ isSendActionEnabled = true,
+ shouldShowRecordAction = false,
+ onContactAttachClick = {},
+ onMediaPickerClick = {},
+ onLockedAudioRecordingStartRequest = {},
+ onMessageTextChange = {},
+ onAudioRecordingStartRequest = {},
+ onAudioRecordingFinish = {},
+ onAudioRecordingLock = { false },
+ onAudioRecordingCancel = {},
+ onSendClick = {},
+ onSendActionLongClick = {},
+ onSubjectChipClick = {},
+ onSubjectChipClear = {},
+ )
+ }
}
}
}
@@ -74,7 +86,12 @@ class ConversationComposeBarLayoutTest {
assertEquals(
textFieldHeight.value,
sendButtonHeight.value,
- 0.5f,
+ HEIGHT_ASSERTION_DELTA_DP,
)
}
+
+ private companion object {
+ private const val DEFAULT_FONT_SCALE = 1f
+ private const val HEIGHT_ASSERTION_DELTA_DP = 0.5f
+ }
}
diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/recipientselection/component/row/BaseRecipientSelectionContactRowTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/recipientselection/component/row/BaseRecipientSelectionContactRowTest.kt
index 0cd6c4192..90719508c 100644
--- a/app/src/sharedTest/kotlin/com/android/messaging/ui/recipientselection/component/row/BaseRecipientSelectionContactRowTest.kt
+++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/recipientselection/component/row/BaseRecipientSelectionContactRowTest.kt
@@ -1,5 +1,6 @@
package com.android.messaging.ui.recipientselection.component.row
+import androidx.annotation.StringRes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.unit.dp
@@ -88,6 +89,7 @@ internal abstract class BaseRecipientSelectionContactRowTest {
rowDecorators: RecipientSelectionRowDecorators = defaultRowDecorators(),
onRecipientDestinationLongClick: OnRecipientDestinationAction? =
onContentDestinationLongClick,
+ @StringRes emptyStateText: Int = R.string.contact_list_empty_text,
) {
composeTestRule.setContent {
AppTheme {
@@ -98,7 +100,7 @@ internal abstract class BaseRecipientSelectionContactRowTest {
onLoadMore = onLoadMore,
onPrimaryActionClick = onPrimaryActionClick,
onRecipientDestinationLongClick = onRecipientDestinationLongClick,
- emptyStateText = R.string.contact_list_empty_text,
+ emptyStateText = emptyStateText,
)
}
}
diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDetailsMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDetailsMapperImplTest.kt
index 43d15a6d9..4b9db5ada 100644
--- a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDetailsMapperImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDetailsMapperImplTest.kt
@@ -24,6 +24,7 @@ internal class ConversationMessageDetailsMapperImplTest {
isSms = true,
isIncoming = true,
senderNormalizedDestination = "+15550100",
+ senderDisplayDestination = "+1 555-0100",
sentTimeStamp = 1_000L,
receivedTimeStamp = 2_000L,
)
@@ -35,7 +36,7 @@ internal class ConversationMessageDetailsMapperImplTest {
)
assertEquals(ConversationMessageDetails.Type.SMS, result.type)
- assertEquals("+15550100", result.sender)
+ assertEquals("+1 555-0100", result.sender)
assertEquals(1_000L, result.sentTimestamp)
assertEquals(2_000L, result.receivedTimestamp)
assertNull(result.priority)
@@ -171,15 +172,18 @@ internal class ConversationMessageDetailsMapperImplTest {
participant(
id = "sender",
normalizedDestination = "+10000000000",
+ displayDestination = "+1 000-000-0000",
),
participant(
id = "recipient",
normalizedDestination = "+19999999999",
+ displayDestination = "+1 999-999-9999",
),
participant(
id = "self",
isSelf = true,
normalizedDestination = "+15555555555",
+ displayDestination = "+1 555-555-5555",
),
)
@@ -192,7 +196,7 @@ internal class ConversationMessageDetailsMapperImplTest {
debug = null,
)
- assertEquals(listOf("+19999999999"), result.recipients)
+ assertEquals(listOf("+1 999-999-9999"), result.recipients)
}
@Test
@@ -205,6 +209,7 @@ internal class ConversationMessageDetailsMapperImplTest {
participant(
id = "recipient",
normalizedDestination = " ",
+ displayDestination = " ",
),
)
@@ -344,6 +349,7 @@ internal class ConversationMessageDetailsMapperImplTest {
isIncoming: Boolean = false,
isSendComplete: Boolean = false,
senderNormalizedDestination: String? = null,
+ senderDisplayDestination: String? = null,
participantId: String? = null,
selfParticipantId: String? = null,
sentTimeStamp: Long = 0L,
@@ -356,6 +362,7 @@ internal class ConversationMessageDetailsMapperImplTest {
every { this@mockk.isIncoming } returns isIncoming
every { this@mockk.isSendComplete } returns isSendComplete
every { this@mockk.senderNormalizedDestination } returns senderNormalizedDestination
+ every { this@mockk.senderDisplayDestination } returns senderDisplayDestination
every { this@mockk.participantId } returns participantId
every { this@mockk.selfParticipantId } returns selfParticipantId
every { this@mockk.sentTimeStamp } returns sentTimeStamp
@@ -369,6 +376,7 @@ internal class ConversationMessageDetailsMapperImplTest {
id: String,
isSelf: Boolean = false,
normalizedDestination: String? = null,
+ displayDestination: String? = null,
isActiveSubscription: Boolean = false,
isDefaultSelf: Boolean = false,
subscriptionName: String? = null,
@@ -378,6 +386,7 @@ internal class ConversationMessageDetailsMapperImplTest {
every { this@mockk.id } returns id
every { this@mockk.isSelf } returns isSelf
every { this@mockk.normalizedDestination } returns normalizedDestination
+ every { this@mockk.displayDestination } returns displayDestination
every { this@mockk.isActiveSubscription } returns isActiveSubscription
every { this@mockk.isDefaultSelf } returns isDefaultSelf
every { this@mockk.subscriptionName } returns subscriptionName
diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt
index ed2e03f82..c808f3cea 100644
--- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryMessagesTest.kt
@@ -413,6 +413,245 @@ internal class ConversationsRepositoryMessagesTest : BaseConversationsRepository
}
}
+ @Test
+ fun getConversationMessages_doesNotClusterFailedMessageWithDeliveredNeighbours() {
+ runTest(
+ context = mainDispatcherRule.testDispatcher
+ ) {
+ val registeredObservers = mutableListOf()
+ val capturedProjections = mutableListOf?>()
+ val repository = createRepository()
+ val expectedUri = MessagingContentProvider.buildConversationMessagesUri(
+ CONVERSATION_ID.value
+ )
+ val messagesInUiOrder = listOf(
+ messageRow(
+ messageId = "delivered-before",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 0L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED,
+ text = "This one went through fine",
+ ),
+ messageRow(
+ messageId = "failed",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 10_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_FAILED,
+ text = "This one did not go through",
+ ),
+ messageRow(
+ messageId = "delivered-after",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 20_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED,
+ text = "This one went through fine too",
+ ),
+ messageRow(
+ messageId = "delivered-last",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 30_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED,
+ text = "And so did this one",
+ ),
+ )
+
+ stubObserverRegistration(
+ registeredObservers = registeredObservers,
+ expectedUri = expectedUri,
+ )
+ stubQuery(
+ expectedUri = expectedUri,
+ capturedProjections = capturedProjections,
+ result = createConversationMessagesCursor(rows = messagesInUiOrder.asReversed()),
+ )
+
+ repository.getConversationMessages(conversationId = CONVERSATION_ID).test {
+ val messages = awaitItem()
+
+ assertEquals(
+ messagesInUiOrder.map { it.messageId },
+ messages.map { it.messageId },
+ )
+
+ // The failed message must never hide behind a neighbour's metadata line: it is
+ // the only place the failure is shown, and the only way to reach "tap to retry".
+ assertClusterState(
+ message = messages[0],
+ canClusterWithPrevious = false,
+ canClusterWithNext = false,
+ )
+ assertClusterState(
+ message = messages[1],
+ canClusterWithPrevious = false,
+ canClusterWithNext = false,
+ )
+ // Delivered neighbours on the far side of the failure still cluster together.
+ assertClusterState(
+ message = messages[2],
+ canClusterWithPrevious = false,
+ canClusterWithNext = true,
+ )
+ assertClusterState(
+ message = messages[3],
+ canClusterWithPrevious = true,
+ canClusterWithNext = false,
+ )
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ @Test
+ fun getConversationMessages_doesNotClusterConsecutiveFailedMessages() {
+ runTest(
+ context = mainDispatcherRule.testDispatcher
+ ) {
+ val registeredObservers = mutableListOf()
+ val capturedProjections = mutableListOf?>()
+ val repository = createRepository()
+ val expectedUri = MessagingContentProvider.buildConversationMessagesUri(
+ CONVERSATION_ID.value
+ )
+ val messagesInUiOrder = listOf(
+ messageRow(
+ messageId = "failed-1",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 0L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_FAILED,
+ text = "First failure",
+ ),
+ messageRow(
+ messageId = "failed-2",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 10_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_FAILED,
+ text = "Second failure",
+ ),
+ messageRow(
+ messageId = "failed-3",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 20_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_FAILED,
+ text = "Third failure",
+ ),
+ )
+
+ stubObserverRegistration(
+ registeredObservers = registeredObservers,
+ expectedUri = expectedUri,
+ )
+ stubQuery(
+ expectedUri = expectedUri,
+ capturedProjections = capturedProjections,
+ result = createConversationMessagesCursor(rows = messagesInUiOrder.asReversed()),
+ )
+
+ repository.getConversationMessages(conversationId = CONVERSATION_ID).test {
+ val messages = awaitItem()
+
+ assertEquals(3, messages.size)
+
+ // A run of failures shares one status line otherwise, leaving all but the last
+ // failure unmarked.
+ messages.forEach { message ->
+ assertClusterState(
+ message = message,
+ canClusterWithPrevious = false,
+ canClusterWithNext = false,
+ )
+ }
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
+ @Test
+ fun getConversationMessages_stillClustersOutgoingMessagesWithDifferentSuccessStatuses() {
+ runTest(
+ context = mainDispatcherRule.testDispatcher
+ ) {
+ val registeredObservers = mutableListOf()
+ val capturedProjections = mutableListOf?>()
+ val repository = createRepository()
+ val expectedUri = MessagingContentProvider.buildConversationMessagesUri(
+ CONVERSATION_ID.value
+ )
+ val messagesInUiOrder = listOf(
+ messageRow(
+ messageId = "complete",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 0L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_COMPLETE,
+ text = "Sent, no delivery report yet",
+ ),
+ messageRow(
+ messageId = "delivered",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 10_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_DELIVERED,
+ text = "This one got its delivery report",
+ ),
+ messageRow(
+ messageId = "sending",
+ participantId = "self-sender",
+ selfParticipantId = "self-1",
+ receivedTimestamp = 20_000L,
+ status = MessageData.BUGLE_STATUS_OUTGOING_SENDING,
+ text = "Still in flight",
+ ),
+ )
+
+ stubObserverRegistration(
+ registeredObservers = registeredObservers,
+ expectedUri = expectedUri,
+ )
+ stubQuery(
+ expectedUri = expectedUri,
+ capturedProjections = capturedProjections,
+ result = createConversationMessagesCursor(rows = messagesInUiOrder.asReversed()),
+ )
+
+ repository.getConversationMessages(conversationId = CONVERSATION_ID).test {
+ val messages = awaitItem()
+
+ assertEquals(3, messages.size)
+
+ // Only failures break a cluster. Delivery reports land one message at a time and
+ // in-flight messages settle through several statuses, so a successful run is
+ // routinely a mix of them -- splitting on status alone would tear apart ordinary
+ // threads and make every arriving report re-flow the list.
+ assertClusterState(
+ message = messages[0],
+ canClusterWithPrevious = false,
+ canClusterWithNext = true,
+ )
+ assertClusterState(
+ message = messages[1],
+ canClusterWithPrevious = true,
+ canClusterWithNext = true,
+ )
+ assertClusterState(
+ message = messages[2],
+ canClusterWithPrevious = true,
+ canClusterWithNext = false,
+ )
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+ }
+
@Test
fun getConversationMessages_returnsEmptyListWhenQueryReturnsNull() {
runTest(
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt b/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt
index d5f5171c6..87bdd6142 100644
--- a/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/ActionSyncTestDatabase.kt
@@ -6,6 +6,8 @@ import android.database.sqlite.SQLiteDatabase
internal fun createInMemoryActionSyncTestDatabase(context: Context): DatabaseWrapper {
val sqliteDatabase = SQLiteDatabase.create(null)
DatabaseHelper.rebuildTables(sqliteDatabase)
+ // Match the connections DatabaseHelper.onConfigure() hands the app.
+ sqliteDatabase.setForeignKeyConstraintsEnabled(true)
return DatabaseWrapper(context, sqliteDatabase)
}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt
index 8a784eb23..c83a3716b 100644
--- a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsBlockedConversationTest.kt
@@ -3,6 +3,7 @@ package com.android.messaging.datamodel
import android.media.AudioManager
import android.net.Uri
import com.android.messaging.FactoryTestAccess
+import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery
import com.android.messaging.datamodel.data.ConversationListItemData
import com.android.messaging.testutil.installTestFactory
import com.android.messaging.util.RingtoneUtil
@@ -35,6 +36,7 @@ class BugleNotificationsBlockedConversationTest {
every { dataModel.getDatabase() } returns database
silenceRinger()
stubConversationLookup()
+ stubSnoozeLookup()
stubNotificationDelivery()
}
@@ -120,6 +122,11 @@ class BugleNotificationsBlockedConversationTest {
} returns convData
}
+ private fun stubSnoozeLookup() {
+ mockkStatic(ConversationSnoozeQuery::class)
+ every { ConversationSnoozeQuery.isConversationSnoozed(any()) } returns false
+ }
+
private fun stubNotificationDelivery() {
mockkStatic(MessageNotificationState::class)
mockkStatic(RingtoneUtil::class)
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMultipleConversationsTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMultipleConversationsTest.kt
new file mode 100644
index 000000000..015326b39
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMultipleConversationsTest.kt
@@ -0,0 +1,206 @@
+package com.android.messaging.datamodel
+
+import androidx.core.app.NotificationManagerCompat
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery
+import com.android.messaging.datamodel.data.ConversationListItemData
+import com.android.messaging.testutil.installTestFactory
+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
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+class BugleNotificationsMultipleConversationsTest {
+
+ private val database = mockk(relaxed = true)
+ private val dataModel = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ installTestFactory(
+ context = RuntimeEnvironment.getApplication().applicationContext,
+ dataModel = dataModel,
+ )
+ every { dataModel.getDatabase() } returns database
+ stubConversationLookup()
+ stubSnoozeLookup()
+ mockkStatic(MessageNotificationState::class)
+ mockkStatic(BugleNotifications::class)
+ every { BugleNotifications.processAndSend(any(), any()) } just runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun createMessageNotification_withSeveralUnseenConversations_notifiesEveryConversation() {
+ val newest = createNotificationConversation("18")
+ val older = createNotificationConversation("19")
+ val oldest = createNotificationConversation("23")
+ givenUnseenMessages(newest, older, oldest)
+
+ BugleNotifications.createMessageNotification(newest.mConversationId)
+
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), newest) }
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), older) }
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), oldest) }
+ }
+
+ @Test
+ fun createMessageNotification_forOlderConversation_stillNotifiesThatConversation() {
+ // The MMS-download and delete-conversation paths call update() for a conversation that is
+ // not the newest unseen one. That conversation is the whole point of the call.
+ val newest = createNotificationConversation("18")
+ val downloaded = createNotificationConversation("19")
+ givenUnseenMessages(newest, downloaded)
+
+ BugleNotifications.createMessageNotification(downloaded.mConversationId)
+
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), downloaded) }
+ }
+
+ @Test
+ fun createMessageNotification_withBlockedConversationAmongUnseen_notifiesTheRest() {
+ val newest = createNotificationConversation("18")
+ val blocked = createNotificationConversation(BLOCKED_CONVERSATION_ID)
+ val oldest = createNotificationConversation("23")
+ givenUnseenMessages(newest, blocked, oldest)
+
+ BugleNotifications.createMessageNotification(newest.mConversationId)
+
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), newest) }
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), oldest) }
+ verify(exactly = 0) { BugleNotifications.processAndSend(any(), blocked) }
+ verify(exactly = 2) { BugleNotifications.processAndSend(any(), any()) }
+ }
+
+ @Test
+ fun createMessageNotification_withSnoozedConversationAmongUnseen_notifiesTheRest() {
+ // update() only rejects a snoozed *argument*; nothing filtered the list itself, so
+ // iterating it would notify every snoozed conversation on every update (BUG-014).
+ val newest = createNotificationConversation("18")
+ val snoozed = createNotificationConversation(SNOOZED_CONVERSATION_ID)
+ givenUnseenMessages(newest, snoozed)
+
+ BugleNotifications.createMessageNotification(newest.mConversationId)
+
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), newest) }
+ verify(exactly = 0) { BugleNotifications.processAndSend(any(), snoozed) }
+ }
+
+ @Test
+ fun createMessageNotification_withMoreUnseenConversationsThanTheCap_notifiesTheNewestOnly() {
+ val conversations = (1..BugleNotifications.MAX_CONVERSATION_NOTIFICATIONS + 3)
+ .map { createNotificationConversation(it.toString()) }
+ givenUnseenMessages(*conversations.toTypedArray())
+
+ BugleNotifications.createMessageNotification(conversations.first().mConversationId)
+
+ verify(exactly = BugleNotifications.MAX_CONVERSATION_NOTIFICATIONS) {
+ BugleNotifications.processAndSend(any(), any())
+ }
+ conversations.take(BugleNotifications.MAX_CONVERSATION_NOTIFICATIONS).forEach {
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), it) }
+ }
+ assertTrue(
+ "the conversations over the cap were dropped without a trace",
+ activeNotificationTags().any {
+ it.endsWith(BugleNotifications.SMS_OVERFLOW_NOTIFICATION_TAG)
+ },
+ )
+ }
+
+ @Test
+ fun createMessageNotification_withFewerUnseenConversationsThanTheCap_postsNoOverflow() {
+ givenUnseenMessages(createNotificationConversation("18"))
+
+ BugleNotifications.createMessageNotification("18")
+
+ assertTrue(
+ "an overflow notification was posted for a single conversation",
+ activeNotificationTags().none {
+ it.endsWith(BugleNotifications.SMS_OVERFLOW_NOTIFICATION_TAG)
+ },
+ )
+ }
+
+ @Test
+ fun createMessageNotification_whenUnseenConversationsFallBackUnderTheCap_cancelsTheOverflow() {
+ val conversations = (1..BugleNotifications.MAX_CONVERSATION_NOTIFICATIONS + 3)
+ .map { createNotificationConversation(it.toString()) }
+ givenUnseenMessages(*conversations.toTypedArray())
+ BugleNotifications.createMessageNotification(conversations.first().mConversationId)
+
+ givenUnseenMessages(*conversations.take(2).toTypedArray())
+ BugleNotifications.createMessageNotification(conversations.first().mConversationId)
+
+ assertTrue(
+ "the overflow notification outlived the conversations it stood for",
+ activeNotificationTags().none {
+ it.endsWith(BugleNotifications.SMS_OVERFLOW_NOTIFICATION_TAG)
+ },
+ )
+ }
+
+ private fun activeNotificationTags(): List {
+ return NotificationManagerCompat.from(RuntimeEnvironment.getApplication())
+ .activeNotifications
+ .map { it.tag }
+ }
+
+ private fun stubConversationLookup() {
+ mockkStatic(ConversationListItemData::class)
+ every { ConversationListItemData.getExistingConversation(database, any()) } returns null
+
+ val blockedData = mockk(relaxed = true)
+ every { blockedData.otherParticipantNormalizedDestination } returns BLOCKED_SENDER
+ every {
+ ConversationListItemData.getExistingConversation(database, BLOCKED_CONVERSATION_ID)
+ } returns blockedData
+
+ mockkStatic(BugleDatabaseOperations::class)
+ every {
+ BugleDatabaseOperations.isBlockedDestination(database, BLOCKED_SENDER)
+ } returns true
+ }
+
+ private fun stubSnoozeLookup() {
+ mockkStatic(ConversationSnoozeQuery::class)
+ every { ConversationSnoozeQuery.isConversationSnoozed(any()) } returns false
+ every {
+ ConversationSnoozeQuery.isConversationSnoozed(SNOOZED_CONVERSATION_ID)
+ } returns true
+ }
+
+ private fun givenUnseenMessages(
+ vararg conversations: MessageNotificationState.Conversation,
+ ) {
+ val conversationsList = MessageNotificationState.ConversationsList(
+ conversations.size,
+ conversations.toList(),
+ )
+ every {
+ MessageNotificationState.getNotificationState()
+ } returns MessageNotificationState(conversationsList)
+ }
+
+ private companion object {
+ private const val BLOCKED_CONVERSATION_ID = "193"
+ private const val BLOCKED_SENDER = "+15551234567"
+ private const val SNOOZED_CONVERSATION_ID = "194"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsSnoozedConversationTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsSnoozedConversationTest.kt
new file mode 100644
index 000000000..6a7792672
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsSnoozedConversationTest.kt
@@ -0,0 +1,110 @@
+package com.android.messaging.datamodel
+
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.data.conversationsettings.repository.ConversationSnoozeQuery
+import com.android.messaging.datamodel.data.ConversationListItemData
+import com.android.messaging.testutil.installTestFactory
+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.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Regression test for BUG-014.
+ *
+ * A snoozed conversation must post no notification, and its unseen messages must not stop another
+ * conversation from posting one. The only snooze check used to sit in [BugleNotifications.update],
+ * where it guards the caller's argument -- so it was bypassed entirely whenever the caller passed
+ * no conversation id, as the boot and app-update paths do.
+ */
+@RunWith(RobolectricTestRunner::class)
+class BugleNotificationsSnoozedConversationTest {
+
+ private val database = mockk(relaxed = true)
+ private val dataModel = mockk(relaxed = true)
+
+ @Before
+ fun setUp() {
+ installTestFactory(
+ context = RuntimeEnvironment.getApplication().applicationContext,
+ dataModel = dataModel,
+ )
+ every { dataModel.getDatabase() } returns database
+ stubConversationLookup()
+ stubSnoozeLookup()
+ mockkStatic(MessageNotificationState::class)
+ mockkStatic(BugleNotifications::class)
+ every { BugleNotifications.processAndSend(any(), any()) } just runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun createMessageNotification_whenSnoozedConversationIsNewest_notifiesTheOtherOne() {
+ // The device repro: the snoozed conversation holds the newest unseen message, so it is the
+ // one the selection lands on -- and the conversation that actually received a message
+ // stays silent.
+ val snoozed = createNotificationConversation(SNOOZED_CONVERSATION_ID)
+ val allowed = createNotificationConversation(ALLOWED_CONVERSATION_ID)
+ givenUnseenMessages(snoozed, allowed)
+
+ BugleNotifications.createMessageNotification(ALLOWED_CONVERSATION_ID)
+
+ verify(exactly = 1) { BugleNotifications.processAndSend(any(), allowed) }
+ verify(exactly = 0) { BugleNotifications.processAndSend(any(), snoozed) }
+ }
+
+ @Test
+ fun createMessageNotification_withoutConversationId_skipsSnoozedConversation() {
+ // Boot and app update pass no conversation id, which bypasses the guard in update().
+ val snoozed = createNotificationConversation(SNOOZED_CONVERSATION_ID)
+ givenUnseenMessages(snoozed)
+
+ BugleNotifications.createMessageNotification(null)
+
+ verify(exactly = 0) { BugleNotifications.processAndSend(any(), any()) }
+ }
+
+ private fun stubConversationLookup() {
+ mockkStatic(ConversationListItemData::class)
+ every { ConversationListItemData.getExistingConversation(database, any()) } returns null
+ }
+
+ private fun stubSnoozeLookup() {
+ mockkStatic(ConversationSnoozeQuery::class)
+ every { ConversationSnoozeQuery.isConversationSnoozed(any()) } returns false
+ every {
+ ConversationSnoozeQuery.isConversationSnoozed(SNOOZED_CONVERSATION_ID)
+ } returns true
+ }
+
+ private fun givenUnseenMessages(
+ vararg conversations: MessageNotificationState.Conversation,
+ ) {
+ val conversationsList = MessageNotificationState.ConversationsList(
+ conversations.size,
+ conversations.toList(),
+ )
+ every {
+ MessageNotificationState.getNotificationState()
+ } returns MessageNotificationState(conversationsList)
+ }
+
+ private companion object {
+ private const val SNOOZED_CONVERSATION_ID = "194"
+ private const val ALLOWED_CONVERSATION_ID = "195"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseHelperForeignKeysTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseHelperForeignKeysTest.kt
new file mode 100644
index 000000000..543620d89
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseHelperForeignKeysTest.kt
@@ -0,0 +1,250 @@
+package com.android.messaging.datamodel
+
+import android.database.sqlite.SQLiteDatabase
+import androidx.core.content.contentValuesOf
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.R
+import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns
+import com.android.messaging.datamodel.DatabaseHelper.ConversationParticipantsColumns
+import com.android.messaging.datamodel.DatabaseHelper.MESSAGES_TABLE
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns
+import com.android.messaging.datamodel.DatabaseHelper.PartColumns
+import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns
+import com.android.messaging.testutil.installTestFactory
+import io.mockk.unmockkAll
+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
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Every delete path in BugleDatabaseOperations deletes messages and conversations only, and leaves
+ * parts and conversation_participants to the ON DELETE CASCADE the schema declares. That holds only
+ * while foreign keys are enforced on the connection the app itself opens.
+ */
+@RunWith(RobolectricTestRunner::class)
+class DatabaseHelperForeignKeysTest {
+
+ @Before
+ fun setUp() {
+ installTestFactory(context = RuntimeEnvironment.getApplication().applicationContext)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun openedDatabase_cascadesDeletesToPartsAndConversationParticipants() {
+ val db = DatabaseHelper.getInstance(applicationContext).writableDatabase
+
+ val conversationId = db.insertConversation()
+ db.insertConversationParticipant(conversationId, db.insertParticipant("+15550001"))
+ val keptMessageId = db.insertMessage(conversationId)
+ db.insertPart(db.insertMessage(conversationId), conversationId, "deleted message body")
+ db.insertPart(keptMessageId, conversationId, "kept message body")
+
+ db.delete(
+ DatabaseHelper.MESSAGES_TABLE,
+ "${MessageColumns._ID} NOT IN (?)",
+ arrayOf(keptMessageId),
+ )
+
+ assertEquals(
+ "parts of a deleted message still hold its text",
+ listOf("kept message body"),
+ db.partTexts(),
+ )
+
+ db.delete(DatabaseHelper.CONVERSATIONS_TABLE, "_id=?", arrayOf(conversationId))
+
+ assertEquals("parts outlived their conversation", emptyList(), db.partTexts())
+ assertEquals(
+ "conversation_participants outlived their conversation",
+ 0,
+ db.countRows(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE),
+ )
+ }
+
+ @Test
+ fun upgrade_purgesRowsOrphanedWhileCascadesWereInert() {
+ val currentVersion = applicationContext.getString(R.string.database_version).toInt()
+
+ SQLiteDatabase.create(null).use { db ->
+ DatabaseHelper.rebuildTables(db)
+ // Reproduce a pre-fix database, where nothing stopped children outliving their parent.
+ db.execSQL("PRAGMA foreign_keys=OFF")
+ val conversationId = db.insertConversation()
+ val participantId = db.insertParticipant("+15550001")
+ val liveMessageId = db.insertMessage(conversationId, senderId = participantId)
+ db.insertPart(liveMessageId, conversationId, "live message body")
+ // One row per foreign key the schema declares, each pointing at a row that is gone.
+ db.insertPart(DELETED_ROW_ID, conversationId, "message-less part")
+ db.insertPart(liveMessageId, DELETED_ROW_ID, "conversation-less part")
+ db.insertMessage(DELETED_ROW_ID)
+ db.insertConversationParticipant(conversationId, participantId)
+ db.insertConversationParticipant(DELETED_ROW_ID, participantId)
+ db.insertConversationParticipant(conversationId, DELETED_ROW_ID)
+ val danglingSenderId = db.insertMessage(conversationId, senderId = DELETED_ROW_ID)
+
+ db.execSQL("PRAGMA foreign_keys=ON")
+
+ DatabaseUpgradeHelper().doOnUpgrade(db, 3, currentVersion)
+
+ assertEquals(
+ "orphaned parts still hold the text of deleted messages",
+ listOf("live message body"),
+ db.partTexts(),
+ )
+ assertEquals(
+ "orphaned conversation_participants survived the upgrade",
+ 1,
+ db.countRows(DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE),
+ )
+ assertEquals("orphaned messages survived the upgrade", 2, db.countRows(MESSAGES_TABLE))
+ assertEquals(
+ "a sender_id pointing at a deleted participant was not nulled out",
+ null,
+ db.senderIdOf(danglingSenderId),
+ )
+ assertEquals(
+ "a sender_id pointing at a live participant was nulled out",
+ participantId,
+ db.senderIdOf(liveMessageId),
+ )
+ assertEquals("upgrade left foreign key violations behind", 0, db.foreignKeyViolations())
+ }
+ }
+
+ /** onDowngrade() and the failed-upgrade path wipe a populated database while cascades run. */
+ @Test
+ fun rebuildTables_dropsEveryTableWhileForeignKeysAreEnforced() {
+ SQLiteDatabase.create(null).use { db ->
+ DatabaseHelper.rebuildTables(db)
+ db.setForeignKeyConstraintsEnabled(true)
+ val conversationId = db.insertConversation()
+ db.insertPart(db.insertMessage(conversationId), conversationId, "message body")
+ db.insertConversationParticipant(conversationId, db.insertParticipant("+15550001"))
+
+ db.beginTransaction()
+ try {
+ DatabaseHelper.rebuildTables(db)
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+
+ assertEquals("rebuild left rows behind", emptyList(), db.partTexts())
+ assertEquals(
+ "rebuild left the conversations table behind",
+ 0,
+ db.countRows(DatabaseHelper.CONVERSATIONS_TABLE),
+ )
+ }
+ }
+
+ private val applicationContext
+ get() = RuntimeEnvironment.getApplication().applicationContext
+
+ private fun SQLiteDatabase.insertConversation(): String {
+ return insertOrThrow(
+ DatabaseHelper.CONVERSATIONS_TABLE,
+ null,
+ contentValuesOf(ConversationColumns.NAME to "Conversation"),
+ ).toString()
+ }
+
+ private fun SQLiteDatabase.insertParticipant(destination: String): String {
+ return insertOrThrow(
+ DatabaseHelper.PARTICIPANTS_TABLE,
+ null,
+ contentValuesOf(ParticipantColumns.NORMALIZED_DESTINATION to destination),
+ ).toString()
+ }
+
+ private fun SQLiteDatabase.insertConversationParticipant(
+ conversationId: String,
+ participantId: String,
+ ) {
+ insertOrThrow(
+ DatabaseHelper.CONVERSATION_PARTICIPANTS_TABLE,
+ null,
+ contentValuesOf(
+ ConversationParticipantsColumns.CONVERSATION_ID to conversationId,
+ ConversationParticipantsColumns.PARTICIPANT_ID to participantId,
+ ),
+ )
+ }
+
+ private fun SQLiteDatabase.insertMessage(
+ conversationId: String,
+ senderId: String? = null,
+ ): String {
+ return insertOrThrow(
+ DatabaseHelper.MESSAGES_TABLE,
+ null,
+ contentValuesOf(
+ MessageColumns.CONVERSATION_ID to conversationId,
+ MessageColumns.SENDER_PARTICIPANT_ID to senderId,
+ ),
+ ).toString()
+ }
+
+ private fun SQLiteDatabase.senderIdOf(messageId: String): String? {
+ val sql = "SELECT ${MessageColumns.SENDER_PARTICIPANT_ID} FROM $MESSAGES_TABLE WHERE _id=?"
+ return rawQuery(sql, arrayOf(messageId)).use { cursor ->
+ cursor.moveToFirst()
+ cursor.getString(0)
+ }
+ }
+
+ private fun SQLiteDatabase.foreignKeyViolations(): Int {
+ return rawQuery("SELECT COUNT(*) FROM pragma_foreign_key_check", null).use { cursor ->
+ cursor.moveToFirst()
+ cursor.getInt(0)
+ }
+ }
+
+ private fun SQLiteDatabase.insertPart(
+ messageId: String,
+ conversationId: String,
+ text: String,
+ ) {
+ insertOrThrow(
+ DatabaseHelper.PARTS_TABLE,
+ null,
+ contentValuesOf(
+ PartColumns.MESSAGE_ID to messageId,
+ PartColumns.CONVERSATION_ID to conversationId,
+ PartColumns.TEXT to text,
+ ),
+ )
+ }
+
+ private fun SQLiteDatabase.partTexts(): List {
+ val texts = mutableListOf()
+ val sql = "SELECT ${PartColumns.TEXT} FROM ${DatabaseHelper.PARTS_TABLE}"
+ rawQuery(sql, null).use { cursor ->
+ while (cursor.moveToNext()) {
+ texts += cursor.getString(0)
+ }
+ }
+ return texts
+ }
+
+ private fun SQLiteDatabase.countRows(table: String): Int {
+ return rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor ->
+ cursor.moveToFirst()
+ cursor.getInt(0)
+ }
+ }
+
+ private companion object {
+ const val DELETED_ROW_ID = "9001"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt
index 2251d6c72..266335523 100644
--- a/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt
@@ -2,15 +2,72 @@ package com.android.messaging.datamodel
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
+import androidx.core.content.contentValuesOf
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.R
import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns
+import com.android.messaging.datamodel.data.ConversationListItemData
+import com.android.messaging.testutil.installTestFactory
+import io.mockk.unmockkAll
+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
@RunWith(RobolectricTestRunner::class)
class DatabaseUpgradeHelperTest {
+ @Before
+ fun setUp() {
+ installTestFactory(context = RuntimeEnvironment.getApplication().applicationContext)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ /**
+ * doUpgradeWithExceptions() throws unless a handler carries the version all the way to the
+ * current one, and doOnUpgrade() answers that by rebuilding every table - which drops all of
+ * the user's messages. So every bump of R.string.database_version needs its own handler, even
+ * a handler that changes no tables because only a view changed.
+ */
+ @Test
+ fun upgradeFromVersion3_keepsExistingDataAndRebuildsViews() {
+ val context = RuntimeEnvironment.getApplication().applicationContext
+ val currentVersion = context.getString(R.string.database_version).toInt()
+
+ SQLiteDatabase.create(null).use { db ->
+ DatabaseHelper.rebuildTables(db)
+ db.insert(
+ DatabaseHelper.CONVERSATIONS_TABLE,
+ null,
+ contentValuesOf(ConversationColumns.NAME to "Weekend plan"),
+ )
+
+ DatabaseUpgradeHelper().doOnUpgrade(db, 3, currentVersion)
+
+ assertEquals(
+ "upgrade wiped the conversations table",
+ 1,
+ db.countRows(DatabaseHelper.CONVERSATIONS_TABLE),
+ )
+ assertTrue(
+ "conversation_list_view was not rebuilt",
+ db.hasColumn(
+ ConversationListItemData.getConversationListView(),
+ "snippet_sender_full_name",
+ ),
+ )
+ }
+ }
+
@Test
fun upgradeToVersion3_createsPinnedColumnAndIndex() {
val table = DatabaseHelper.CONVERSATIONS_TABLE
@@ -32,6 +89,13 @@ class DatabaseUpgradeHelperTest {
}
}
+ private fun SQLiteDatabase.countRows(table: String): Int {
+ return rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor ->
+ cursor.moveToFirst()
+ cursor.getInt(0)
+ }
+ }
+
private fun SQLiteDatabase.hasIndex(name: String): Boolean {
return rawQuery(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/MessageNotificationStateFailedMessagesTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/MessageNotificationStateFailedMessagesTest.kt
index 4ad655979..98b4aff45 100644
--- a/app/src/test/kotlin/com/android/messaging/datamodel/MessageNotificationStateFailedMessagesTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/MessageNotificationStateFailedMessagesTest.kt
@@ -1,11 +1,15 @@
package com.android.messaging.datamodel
+import android.app.Notification
import android.app.NotificationManager
+import android.content.ContentValues
import android.content.Context
import androidx.core.content.contentValuesOf
import com.android.messaging.FactoryTestAccess
+import com.android.messaging.R
import com.android.messaging.datamodel.data.MessageData
import com.android.messaging.testutil.installTestFactory
+import com.android.messaging.util.ContentType
import com.android.messaging.util.NotificationChannelUtil
import com.android.messaging.util.PendingIntentConstants
import io.mockk.every
@@ -54,9 +58,9 @@ class MessageNotificationStateFailedMessagesTest {
@Test
fun checkFailedMessagesPostsNotificationOnExistingChannel() {
- insertFailedOutgoingMessage()
+ insertFailedMessage()
- MessageNotificationState.checkFailedMessages()
+ checkFailedMessages()
val shadow = shadowOf(notificationManager)
val notification = shadow.getNotification(
@@ -81,8 +85,8 @@ class MessageNotificationStateFailedMessagesTest {
@Test
fun checkFailedMessagesCancelsNotificationOnceMessagesAreSeen() {
- insertFailedOutgoingMessage()
- MessageNotificationState.checkFailedMessages()
+ insertFailedMessage()
+ checkFailedMessages()
DataModel.get().database.update(
DatabaseHelper.MESSAGES_TABLE,
@@ -90,13 +94,145 @@ class MessageNotificationStateFailedMessagesTest {
null,
null,
)
- MessageNotificationState.checkFailedMessages()
+ checkFailedMessages()
val shadow = shadowOf(notificationManager)
assertEquals(0, shadow.size())
}
- private fun insertFailedOutgoingMessage() {
+ @Test
+ fun checkFailedMessagesShowsTheTextOfTheMessageThatFailed() {
+ insertFailedMessage()
+
+ checkFailedMessages()
+
+ assertEquals(
+ "the failure notification does not say which message failed",
+ MESSAGE_TEXT,
+ postedFailureNotification().extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesNamesTheConversationTheMessageWasFor() {
+ insertFailedMessage()
+
+ checkFailedMessages()
+
+ assertEquals(
+ "the failure notification does not say which conversation failed",
+ CONVERSATION_NAME,
+ postedFailureNotification().extras
+ .getCharSequence(Notification.EXTRA_SUB_TEXT).toString(),
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesStampsTheNotificationWithTheTimeOfTheFailedMessage() {
+ insertFailedMessage()
+
+ checkFailedMessages()
+
+ assertEquals(
+ "the failure notification is stamped with the time of the check, not the message",
+ RECEIVED_TIMESTAMP_MILLIS,
+ postedFailureNotification().`when`,
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesOffersToSendTheFailedMessageAgain() {
+ insertFailedMessage()
+
+ checkFailedMessages()
+
+ val actions = postedFailureNotification().actions
+ assertNotNull("the failure notification offers no way to retry", actions)
+ assertEquals("expected exactly one action on the failure notification", 1, actions.size)
+ assertEquals(
+ context.getString(R.string.notification_retry_prompt),
+ actions.single().title.toString(),
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesDescribesTheAttachmentWhenTheMessageHasNoText() {
+ insertFailedMessage(
+ partValues = contentValuesOf(
+ DatabaseHelper.PartColumns.CONTENT_URI to "content://mms/part/1",
+ DatabaseHelper.PartColumns.CONTENT_TYPE to ContentType.IMAGE_PNG,
+ ),
+ )
+
+ checkFailedMessages()
+
+ assertEquals(
+ "an attachment-only message leaves the failure notification blank",
+ context.getString(R.string.notification_picture),
+ postedFailureNotification().extras.getCharSequence(Notification.EXTRA_TEXT).toString(),
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesOffersToDownloadAgainWhenTheDownloadFailed() {
+ insertFailedMessage(status = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED)
+
+ checkFailedMessages()
+
+ val actions = postedFailureNotification().actions
+ assertNotNull("the failure notification offers no way to retry the download", actions)
+ assertEquals(
+ "a failed download must offer to download again, not to send again",
+ context.getString(R.string.notification_download_mms),
+ actions.single().title.toString(),
+ )
+ }
+
+ @Test
+ fun checkFailedMessagesFallsBackToTheSentTimeWhenNothingWasReceived() {
+ insertFailedMessage(receivedTimestamp = 0L)
+
+ checkFailedMessages()
+
+ assertEquals(
+ "a message with no received time leaves the notification stamped with the check",
+ SENT_TIMESTAMP_MILLIS,
+ postedFailureNotification().`when`,
+ )
+ }
+
+ private fun checkFailedMessages() {
+ var failure: Throwable? = null
+ val thread = Thread(
+ { MessageNotificationState.checkFailedMessages() },
+ "notification-check",
+ )
+ thread.setUncaughtExceptionHandler { _, throwable -> failure = throwable }
+ thread.start()
+ thread.join()
+ failure?.let { throw it }
+ }
+
+ private fun postedFailureNotification(): Notification {
+ val notification = shadowOf(notificationManager).getNotification(
+ BugleNotifications.buildNotificationTag(
+ PendingIntentConstants.MSG_SEND_ERROR,
+ null,
+ ),
+ PendingIntentConstants.MSG_SEND_ERROR,
+ )
+ assertNotNull("failure notification was not posted", notification)
+ return notification
+ }
+
+ private fun insertFailedMessage(
+ partValues: ContentValues = contentValuesOf(
+ DatabaseHelper.PartColumns.TEXT to MESSAGE_TEXT,
+ DatabaseHelper.PartColumns.CONTENT_TYPE to ContentType.TEXT_PLAIN,
+ ),
+ status: Int = MessageData.BUGLE_STATUS_OUTGOING_FAILED,
+ receivedTimestamp: Long = RECEIVED_TIMESTAMP_MILLIS,
+ ) {
val db = DataModel.get().database
val participantId = db.insert(
@@ -122,19 +258,25 @@ class MessageNotificationStateFailedMessagesTest {
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_OUTGOING_FAILED,
+ DatabaseHelper.MessageColumns.STATUS to status,
DatabaseHelper.MessageColumns.SEEN to 0,
DatabaseHelper.MessageColumns.READ to 0,
- DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP to RECEIVED_TIMESTAMP_MILLIS,
+ DatabaseHelper.MessageColumns.RECEIVED_TIMESTAMP to receivedTimestamp,
DatabaseHelper.MessageColumns.SENT_TIMESTAMP to SENT_TIMESTAMP_MILLIS,
),
)
assertTrue("message insert failed", messageId >= 0)
+
+ partValues.put(DatabaseHelper.PartColumns.MESSAGE_ID, messageId)
+ partValues.put(DatabaseHelper.PartColumns.CONVERSATION_ID, conversationId)
+ val partId = db.insert(DatabaseHelper.PARTS_TABLE, null, partValues)
+ assertTrue("part insert failed", partId >= 0)
}
private companion object {
private const val RECIPIENT = "+15551230000"
private const val CONVERSATION_NAME = "Test conversation"
+ private const val MESSAGE_TEXT = "the message that failed to send"
private const val RECEIVED_TIMESTAMP_MILLIS = 1_780_920_000_000L
private const val SENT_TIMESTAMP_MILLIS = 1_780_919_999_000L
}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/action/ProcessDeliveryReportActionTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/action/ProcessDeliveryReportActionTest.kt
new file mode 100644
index 000000000..caad2acbc
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/action/ProcessDeliveryReportActionTest.kt
@@ -0,0 +1,83 @@
+package com.android.messaging.datamodel.action
+
+import android.content.ContentValues
+import android.net.Uri
+import android.provider.Telephony.Sms
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.datamodel.BugleDatabaseOperations
+import com.android.messaging.datamodel.DataModel
+import com.android.messaging.datamodel.DatabaseHelper.MessageColumns
+import com.android.messaging.datamodel.MessagingContentProvider
+import com.android.messaging.datamodel.data.MessageData
+import com.android.messaging.sms.MmsUtils
+import com.android.messaging.testutil.installTestFactory
+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 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
+
+@RunWith(RobolectricTestRunner::class)
+class ProcessDeliveryReportActionTest {
+
+ private val dataModel = mockk(relaxed = true)
+ private val telephonyTimeSent = slot()
+ private val localValues = slot()
+
+ @Before
+ fun setUp() {
+ installTestFactory(
+ context = RuntimeEnvironment.getApplication().applicationContext,
+ dataModel = dataModel,
+ )
+ mockkStatic(MmsUtils::class)
+ every {
+ MmsUtils.updateSmsStatusAndDateSent(any(), any(), capture(telephonyTimeSent))
+ } just runs
+ mockkStatic(BugleDatabaseOperations::class)
+ every { BugleDatabaseOperations.readMessageData(any(), any()) } returns
+ mockk {
+ every { messageId } returns MESSAGE_ID
+ every { conversationId } returns CONVERSATION_ID
+ every { smsMessageUri } returns SMS_MESSAGE_URI
+ }
+ every {
+ BugleDatabaseOperations.updateMessageRow(any(), any(), capture(localValues))
+ } just runs
+ mockkStatic(MessagingContentProvider::class)
+ every { MessagingContentProvider.notifyMessagesChanged(any()) } just runs
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun deliveryReportStoresTheSameSentTimestampLocallyAsInTelephony() {
+ val before = System.currentTimeMillis()
+ ProcessDeliveryReportAction(SMS_MESSAGE_URI, Sms.STATUS_COMPLETE).executeAction()
+ val after = System.currentTimeMillis()
+
+ val sentTimestamp = localValues.captured.getAsLong(MessageColumns.SENT_TIMESTAMP)
+ assertEquals(telephonyTimeSent.captured, sentTimestamp)
+ assertTrue(sentTimestamp in before..after)
+ }
+
+ private companion object {
+ private val SMS_MESSAGE_URI: Uri = Uri.parse("content://sms/193")
+ private const val MESSAGE_ID = "193"
+ private const val CONVERSATION_ID = "17"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/action/RedownloadMmsPendingIntentTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/action/RedownloadMmsPendingIntentTest.kt
new file mode 100644
index 000000000..55ed506e2
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/action/RedownloadMmsPendingIntentTest.kt
@@ -0,0 +1,70 @@
+package com.android.messaging.datamodel.action
+
+import android.app.PendingIntent
+import android.content.Context
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.datamodel.BugleNotifications
+import com.android.messaging.testutil.installTestFactory
+import io.mockk.unmockkAll
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+
+@RunWith(RobolectricTestRunner::class)
+class RedownloadMmsPendingIntentTest {
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = RuntimeEnvironment.getApplication().applicationContext
+ installTestFactory(context = context)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun downloadIntentsForTwoConversationsKeepTheirOwnMessage() {
+ val first = redownloadPendingIntent("message-1")
+ val second = redownloadPendingIntent("message-2")
+
+ assertNotEquals(first, second)
+ assertEquals("message-1", messageIdOf(first))
+ assertEquals("message-2", messageIdOf(second))
+ }
+
+ @Test
+ fun downloadIntentForTheSameMessageIsReusedRatherThanDuplicated() {
+ val first = redownloadPendingIntent("message-1")
+ val reposted = redownloadPendingIntent("message-1")
+
+ assertEquals(first, reposted)
+ }
+
+ private fun redownloadPendingIntent(messageId: String): PendingIntent =
+ RedownloadMmsAction.getPendingIntentForRedownloadMms(
+ context,
+ messageId,
+ BugleNotifications.REQUEST_CODE_REDOWNLOAD_MMS,
+ )
+
+ private fun messageIdOf(pendingIntent: PendingIntent): String? {
+ val actionBundle = shadowOf(pendingIntent).savedIntent
+ .getBundleExtra(ActionServiceImpl.EXTRA_ACTION_BUNDLE)!!
+ val action = actionBundle.getParcelable(
+ ActionServiceImpl.BUNDLE_ACTION,
+ Action::class.java,
+ )!!
+ return action.actionParameters.getString("message_id")
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/data/ConversationListItemDataSnippetSenderTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/data/ConversationListItemDataSnippetSenderTest.kt
new file mode 100644
index 000000000..fedf0ff72
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/datamodel/data/ConversationListItemDataSnippetSenderTest.kt
@@ -0,0 +1,170 @@
+package com.android.messaging.datamodel.data
+
+import android.content.Context
+import androidx.core.content.contentValuesOf
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.datamodel.DataModel
+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.DatabaseHelper.ParticipantColumns
+import com.android.messaging.datamodel.DatabaseWrapper
+import com.android.messaging.datamodel.createInMemoryActionSyncTestDatabase
+import com.android.messaging.testutil.installTestFactory
+import com.android.messaging.util.NotificationChannelUtil
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+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
+
+/**
+ * The conversation list prefixes a snippet with the name of whoever sent the latest message.
+ * These cover the fallback order that name resolves through, end to end: the real
+ * `conversation_list_view` SQL, the real projection, and [ConversationListItemData.bind].
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class ConversationListItemDataSnippetSenderTest {
+
+ 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
+
+ NotificationChannelUtil.onCreate(context)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun snippetSenderName_prefersFirstName() {
+ seedConversationWithSender(
+ firstName = "Zoë",
+ fullName = "Zoë Zimmermann",
+ displayDestination = DISPLAY_DESTINATION,
+ )
+
+ assertEquals("Zoë", loadConversationListItem().snippetSenderName)
+ }
+
+ /**
+ * Regression test for BUG-001. The contacts provider yields no given name for CJK names,
+ * mononyms, and contacts stored with only a structured family name, so `first_name` is empty
+ * while `full_name` holds the name the rest of the row already displays. Falling straight
+ * through to the phone number shows a raw number next to the resolved name in the title, and
+ * makes TalkBack read a number out for the same contact.
+ */
+ @Test
+ fun snippetSenderName_fallsBackToFullName_whenContactHasNoFirstName() {
+ seedConversationWithSender(
+ firstName = "",
+ fullName = "李明Wang",
+ displayDestination = DISPLAY_DESTINATION,
+ )
+
+ assertEquals("李明Wang", loadConversationListItem().snippetSenderName)
+ }
+
+ @Test
+ fun snippetSenderName_fallsBackToDisplayDestination_whenContactHasNoName() {
+ seedConversationWithSender(
+ firstName = "",
+ fullName = "",
+ displayDestination = DISPLAY_DESTINATION,
+ )
+
+ assertEquals(DISPLAY_DESTINATION, loadConversationListItem().snippetSenderName)
+ }
+
+ private fun seedConversationWithSender(
+ firstName: String,
+ fullName: String,
+ displayDestination: String,
+ ) {
+ val participantId = database.insert(
+ DatabaseHelper.PARTICIPANTS_TABLE,
+ null,
+ contentValuesOf(
+ ParticipantColumns.NORMALIZED_DESTINATION to NORMALIZED_DESTINATION,
+ ParticipantColumns.SEND_DESTINATION to NORMALIZED_DESTINATION,
+ ParticipantColumns.DISPLAY_DESTINATION to displayDestination,
+ ParticipantColumns.FULL_NAME to fullName,
+ ParticipantColumns.FIRST_NAME to firstName,
+ ),
+ )
+ assertTrue("participant insert failed", participantId >= 0)
+
+ val conversationId = database.insert(
+ DatabaseHelper.CONVERSATIONS_TABLE,
+ null,
+ contentValuesOf(
+ ConversationColumns.NAME to CONVERSATION_NAME,
+ ConversationColumns.PARTICIPANT_COUNT to 2,
+ ConversationColumns.SNIPPET_TEXT to SNIPPET_TEXT,
+ ),
+ )
+ assertTrue("conversation insert failed", conversationId >= 0)
+
+ val messageId = database.insert(
+ DatabaseHelper.MESSAGES_TABLE,
+ null,
+ contentValuesOf(
+ MessageColumns.CONVERSATION_ID to conversationId,
+ MessageColumns.SENDER_PARTICIPANT_ID to participantId,
+ MessageColumns.SELF_PARTICIPANT_ID to participantId,
+ MessageColumns.STATUS to MessageData.BUGLE_STATUS_INCOMING_COMPLETE,
+ MessageColumns.RECEIVED_TIMESTAMP to RECEIVED_TIMESTAMP_MILLIS,
+ ),
+ )
+ assertTrue("message insert failed", messageId >= 0)
+
+ database.update(
+ DatabaseHelper.CONVERSATIONS_TABLE,
+ contentValuesOf(ConversationColumns.LATEST_MESSAGE_ID to messageId),
+ "${ConversationColumns._ID}=?",
+ arrayOf(conversationId.toString()),
+ )
+ }
+
+ private fun loadConversationListItem(): ConversationListItemData {
+ return database.query(
+ ConversationListItemData.getConversationListView(),
+ ConversationListItemData.PROJECTION,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ).use { cursor ->
+ assertTrue("conversation_list_view returned no rows", cursor.moveToFirst())
+ ConversationListItemData().apply { bind(cursor) }
+ }
+ }
+
+ private companion object {
+ private const val NORMALIZED_DESTINATION = "+37255500004"
+ private const val DISPLAY_DESTINATION = "+372 5550 0004"
+ private const val CONVERSATION_NAME = "Zoë, 李明Wang, Bob"
+ private const val SNIPPET_TEXT = "对我来说没问题"
+ private const val RECEIVED_TIMESTAMP_MILLIS = 1_780_920_000_000L
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/domain/media/usecase/ResolveAudioDurationMillisImplTest.kt b/app/src/test/kotlin/com/android/messaging/domain/media/usecase/ResolveAudioDurationMillisImplTest.kt
new file mode 100644
index 000000000..2d27e3424
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/media/usecase/ResolveAudioDurationMillisImplTest.kt
@@ -0,0 +1,94 @@
+package com.android.messaging.domain.media.usecase
+
+import com.android.messaging.testutil.MainDispatcherRule
+import com.android.messaging.util.UriUtil
+import io.mockk.every
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ResolveAudioDurationMillisImplTest {
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ private val resolveAudioDurationMillis = ResolveAudioDurationMillisImpl(
+ ioDispatcher = mainDispatcherRule.testDispatcher,
+ )
+
+ @Before
+ fun setUp() {
+ mockkStatic(UriUtil::class)
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun resolveAudioDurationMillis_readsTheClipLengthFromMetadata() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ every { UriUtil.getMediaDurationMs(any()) } returns DURATION_MILLIS
+
+ assertEquals(DURATION_MILLIS.toLong(), resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+ }
+ }
+
+ @Test
+ fun resolveAudioDurationMillis_reusesTheResolvedDuration() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ every { UriUtil.getMediaDurationMs(any()) } returns DURATION_MILLIS
+
+ assertEquals(DURATION_MILLIS.toLong(), resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+ assertEquals(DURATION_MILLIS.toLong(), resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+
+ verify(exactly = 1) { UriUtil.getMediaDurationMs(any()) }
+ }
+ }
+
+ @Test
+ fun resolveAudioDurationMillis_unreadableMediaReportsUnknownDurationAndIsNotCached() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ every { UriUtil.getMediaDurationMs(any()) } throws IllegalStateException("unreadable")
+
+ assertEquals(0L, resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+
+ every { UriUtil.getMediaDurationMs(any()) } returns DURATION_MILLIS
+
+ assertEquals(DURATION_MILLIS.toLong(), resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+ }
+ }
+
+ @Test
+ fun resolveAudioDurationMillis_negativeMetadataReportsUnknownDuration() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ every { UriUtil.getMediaDurationMs(any()) } returns -1
+
+ assertEquals(0L, resolveAudioDurationMillis(AUDIO_CONTENT_URI))
+ }
+ }
+
+ @Test
+ fun resolveAudioDurationMillis_blankUriSkipsTheMetadataRead() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ assertEquals(0L, resolveAudioDurationMillis(contentUri = ""))
+
+ verify(exactly = 0) { UriUtil.getMediaDurationMs(any()) }
+ }
+ }
+
+ private companion object {
+ private const val AUDIO_CONTENT_URI = "content://mms/part/audio"
+ private const val DURATION_MILLIS = 18_000
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/domain/onboarding/usecase/SelfPhoneNumberPermissionPromptTest.kt b/app/src/test/kotlin/com/android/messaging/domain/onboarding/usecase/SelfPhoneNumberPermissionPromptTest.kt
new file mode 100644
index 000000000..574a90bd7
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/onboarding/usecase/SelfPhoneNumberPermissionPromptTest.kt
@@ -0,0 +1,54 @@
+package com.android.messaging.domain.onboarding.usecase
+
+import com.android.messaging.data.onboarding.store.SelfPhoneNumberPermissionStore
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SelfPhoneNumberPermissionPromptTest {
+
+ @Test
+ fun consume_whenNeverAsked_promptsAndRecordsTheAsk() {
+ val store = mockStore(isGranted = false, isRequested = false)
+
+ assertTrue(SelfPhoneNumberPermissionPromptImpl(store).consume())
+
+ verify(exactly = 1) {
+ store.markRequested()
+ }
+ }
+
+ @Test
+ fun consume_whenAlreadyAsked_doesNotPromptAgain() {
+ val store = mockStore(isGranted = false, isRequested = true)
+
+ assertFalse(
+ "the permission is optional, so a user who declined it once is not asked again",
+ SelfPhoneNumberPermissionPromptImpl(store).consume(),
+ )
+ }
+
+ @Test
+ fun consume_whenPermissionAlreadyGranted_doesNotPrompt() {
+ val store = mockStore(isGranted = true, isRequested = false)
+
+ assertFalse(SelfPhoneNumberPermissionPromptImpl(store).consume())
+
+ verify(exactly = 0) {
+ store.markRequested()
+ }
+ }
+
+ private fun mockStore(
+ isGranted: Boolean,
+ isRequested: Boolean,
+ ): SelfPhoneNumberPermissionStore {
+ return mockk(relaxUnitFun = true).also {
+ every { it.isGranted() } returns isGranted
+ every { it.isRequested() } returns isRequested
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/IsValidSelfPhoneNumberTest.kt b/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/IsValidSelfPhoneNumberTest.kt
new file mode 100644
index 000000000..e72499edc
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/IsValidSelfPhoneNumberTest.kt
@@ -0,0 +1,80 @@
+package com.android.messaging.domain.subscriptionsettings.usecase
+
+import android.content.Context
+import android.telephony.SubscriptionManager
+import com.android.messaging.Factory
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.data.subscription.model.SubId
+import com.android.messaging.testutil.FakeBuglePrefs
+import com.android.messaging.testutil.installTestFactory
+import com.android.messaging.util.PhoneUtils
+import io.mockk.every
+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
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowSubscriptionManager
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class IsValidSelfPhoneNumberTest {
+
+ private val useCase = IsValidSelfPhoneNumberImpl()
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ ShadowSubscriptionManager.reset()
+ context = RuntimeEnvironment.getApplication().applicationContext
+ installTestFactory(context = context, prefs = FakeBuglePrefs())
+ givenSimOnEstonianCarrier()
+ every { Factory.get().getPhoneUtils(any()) } returns PhoneUtils(SUB_ID.value)
+ }
+
+ @After
+ fun tearDown() {
+ ShadowSubscriptionManager.reset()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun invokeAcceptsAnEmptyNumber() {
+ assertTrue(
+ "emptying the field is how the number is handed back to the SIM, so it can never" +
+ " be rejected as invalid",
+ useCase(SUB_ID, ""),
+ )
+ }
+
+ @Test
+ fun invokeAcceptsAPhoneNumber() {
+ assertTrue(useCase(SUB_ID, "5555 0001"))
+ }
+
+ @Test
+ fun invokeRejectsTextThatIsNotAPhoneNumber() {
+ assertFalse(useCase(SUB_ID, "DROP TABLE messages"))
+ }
+
+ private fun givenSimOnEstonianCarrier() {
+ val subscriptionManager = context.getSystemService(SubscriptionManager::class.java)
+ shadowOf(subscriptionManager).setActiveSubscriptionInfos(
+ ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder()
+ .setId(SUB_ID.value)
+ .setCountryIso("ee")
+ .setNumber("+37253953334")
+ .buildSubscriptionInfo(),
+ )
+ }
+
+ private companion object {
+ private val SUB_ID = SubId(2)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberTest.kt b/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberTest.kt
new file mode 100644
index 000000000..f446c8374
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/domain/subscriptionsettings/usecase/SetSubscriptionPhoneNumberTest.kt
@@ -0,0 +1,142 @@
+package com.android.messaging.domain.subscriptionsettings.usecase
+
+import android.content.Context
+import android.telephony.SubscriptionManager
+import com.android.messaging.Factory
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.R
+import com.android.messaging.data.subscription.model.SubId
+import com.android.messaging.testutil.FakeBuglePrefs
+import com.android.messaging.testutil.installTestFactory
+import com.android.messaging.util.PhoneUtils
+import io.mockk.every
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowSubscriptionManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class SetSubscriptionPhoneNumberTest {
+
+ private lateinit var context: Context
+ private lateinit var prefs: FakeBuglePrefs
+ private lateinit var prefKey: String
+
+ @Before
+ fun setUp() {
+ ShadowSubscriptionManager.reset()
+ context = RuntimeEnvironment.getApplication().applicationContext
+ prefs = FakeBuglePrefs()
+ prefKey = context.getString(R.string.mms_phone_number_pref_key)
+ installTestFactory(context = context, prefs = prefs)
+ givenSimOnEstonianCarrier()
+ every { Factory.get().getPhoneUtils(any()) } returns PhoneUtils(SUB_ID)
+ }
+
+ @After
+ fun tearDown() {
+ ShadowSubscriptionManager.reset()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun invokeDoesNotStoreTextThatIsNotAPhoneNumber() = runTest {
+ setPhoneNumber("DROP TABLE messages")
+
+ assertNull(
+ "anything stored here becomes the sender identity of every outgoing MMS," +
+ " so text that is not a phone number must never reach the preference",
+ prefs.getString(prefKey, null),
+ )
+ }
+
+ @Test
+ fun invokeStoresAValidNumberInE164() = runTest {
+ setPhoneNumber("5555 0001")
+
+ assertEquals(
+ "the stored number is written into the MMS From: header, so it has to be" +
+ " normalised rather than kept as typed",
+ "+37255550001",
+ prefs.getString(prefKey, null),
+ )
+ }
+
+ @Test
+ fun invokeStoresANumberLibphonenumberOnlyConsidersPossible() {
+ runTest {
+ setPhoneNumber(POSSIBLE_BUT_UNASSIGNED_NUMBER)
+
+ assertEquals(
+ "libphonenumber's metadata trails real numbering plans, and this preference is" +
+ " the only way to set a number the SIM does not carry, so a number of a" +
+ " plausible length is stored rather than refused",
+ "+3721234567",
+ prefs.getString(prefKey, null),
+ )
+ }
+ }
+
+ @Test
+ fun invokeClearsTheOverrideWhenTheFieldIsEmptied() = runTest {
+ prefs.putString(prefKey, "+3725550001")
+
+ setPhoneNumber("")
+
+ assertNull(
+ "emptying the field is the documented way back to the number the SIM reports",
+ prefs.getString(prefKey, null),
+ )
+ }
+
+ @Test
+ fun invokeClearsTheOverrideWhenTheNumberMatchesTheSim() = runTest {
+ prefs.putString(prefKey, "+3725550001")
+
+ setPhoneNumber(SIM_NUMBER)
+
+ assertNull(
+ "an override that repeats the SIM's own number is not an override",
+ prefs.getString(prefKey, null),
+ )
+ }
+
+ private suspend fun TestScope.setPhoneNumber(phoneNumber: String) {
+ SetSubscriptionPhoneNumberImpl(
+ context = context,
+ ioDispatcher = StandardTestDispatcher(testScheduler),
+ ).invoke(subId = SubId(SUB_ID), phoneNumber = phoneNumber)
+ }
+
+ private fun givenSimOnEstonianCarrier() {
+ val subscriptionManager = context.getSystemService(SubscriptionManager::class.java)
+ shadowOf(subscriptionManager).setActiveSubscriptionInfos(
+ ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder()
+ .setId(SUB_ID)
+ .setCountryIso("ee")
+ .setNumber(SIM_NUMBER)
+ .buildSubscriptionInfo(),
+ )
+ }
+
+ private companion object {
+ private const val SUB_ID = 2
+ private const val SIM_NUMBER = "+37253953334"
+
+ /** Estonian landline length, on a prefix the numbering plan does not assign. */
+ private const val POSSIBLE_BUT_UNASSIGNED_NUMBER = "1234567"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/SubscriptionSettingsViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/SubscriptionSettingsViewModelTest.kt
index a07fb58c6..5e28f4bb6 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/SubscriptionSettingsViewModelTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/SubscriptionSettingsViewModelTest.kt
@@ -3,8 +3,10 @@ package com.android.messaging.ui.appsettings.subscription
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.android.messaging.data.subscription.model.SubId
+import com.android.messaging.domain.subscriptionsettings.usecase.IsValidSelfPhoneNumber
import com.android.messaging.testutil.MainDispatcherRule
import com.android.messaging.ui.appsettings.subscription.delegate.SubscriptionSettingsDelegate
+import com.android.messaging.ui.appsettings.subscription.model.PhoneNumberDialogUiState
import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsAction as Action
import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsNavEvent
import com.android.messaging.ui.appsettings.subscription.model.SubscriptionSettingsScreenEffect
@@ -77,16 +79,22 @@ class SubscriptionSettingsViewModelTest {
}
@Test
- fun onPhoneNumberChanged_delegatesWithSeededSubId() {
+ fun onPhoneNumberConfirmed_delegatesWithSeededSubId() {
runTest(context = mainDispatcherRule.testDispatcher) {
val delegate = mockDelegate()
val viewModel = createViewModel(delegate = delegate, subId = 1)
- viewModel.onAction(Action.PhoneNumberChanged(phoneNumber = "+1555000111"))
+ viewModel.onAction(Action.PhoneNumberClicked)
+ viewModel.onAction(Action.PhoneNumberConfirmed(phoneNumber = "+1555000111"))
verify(exactly = 1) {
delegate.onPhoneNumberChanged(subId = SubId(1), phoneNumber = "+1555000111")
}
+ assertEquals(
+ "a number that was stored leaves nothing to correct, so the dialog closes",
+ PhoneNumberDialogUiState(),
+ viewModel.phoneNumberDialogState.value,
+ )
}
}
@@ -142,12 +150,62 @@ class SubscriptionSettingsViewModelTest {
}
}
+ @Test
+ fun onPhoneNumberConfirmed_whenNotANumber_marksTheDialogInvalidAndKeepsItOpen() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val delegate = mockDelegate()
+ val viewModel = createViewModel(delegate = delegate, isValidPhoneNumber = false)
+ viewModel.onAction(Action.PhoneNumberClicked)
+
+ viewModel.onAction(Action.PhoneNumberConfirmed(phoneNumber = "DROP TABLE messages"))
+
+ assertEquals(
+ PhoneNumberDialogUiState(isVisible = true, isInvalid = true),
+ viewModel.phoneNumberDialogState.value,
+ )
+ verify(exactly = 0) {
+ delegate.onPhoneNumberChanged(any(), any())
+ }
+ }
+ }
+
+ @Test
+ fun onPhoneNumberErrorDismissed_clearsTheRejection() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel(isValidPhoneNumber = false)
+ viewModel.onAction(Action.PhoneNumberClicked)
+ viewModel.onAction(Action.PhoneNumberConfirmed(phoneNumber = "DROP TABLE messages"))
+
+ viewModel.onAction(Action.PhoneNumberErrorDismissed)
+
+ assertEquals(
+ PhoneNumberDialogUiState(isVisible = true, isInvalid = false),
+ viewModel.phoneNumberDialogState.value,
+ )
+ }
+ }
+
+ @Test
+ fun onPhoneNumberDialogDismissed_closesTheDialogAndForgetsTheRejection() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val viewModel = createViewModel(isValidPhoneNumber = false)
+ viewModel.onAction(Action.PhoneNumberClicked)
+ viewModel.onAction(Action.PhoneNumberConfirmed(phoneNumber = "DROP TABLE messages"))
+
+ viewModel.onAction(Action.PhoneNumberDialogDismissed)
+
+ assertEquals(PhoneNumberDialogUiState(), viewModel.phoneNumberDialogState.value)
+ }
+ }
+
private fun createViewModel(
delegate: SubscriptionSettingsDelegate = mockDelegate(),
subId: Int = 1,
+ isValidPhoneNumber: Boolean = true,
): SubscriptionSettingsViewModel {
return SubscriptionSettingsViewModel(
subscriptionSettingsDelegate = delegate,
+ isValidSelfPhoneNumber = { _, _ -> isValidPhoneNumber },
savedStateHandle = SavedStateHandle(
mapOf(SUBSCRIPTION_SETTINGS_SUB_ID_ARG to subId),
),
diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/ui/PhoneNumberDialogTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/ui/PhoneNumberDialogTest.kt
new file mode 100644
index 000000000..c1fda2b6b
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/subscription/ui/PhoneNumberDialogTest.kt
@@ -0,0 +1,87 @@
+package com.android.messaging.ui.appsettings.subscription.ui
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.R
+import com.android.messaging.ui.core.AppTheme
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class PhoneNumberDialogTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val errorText = targetContext.getString(R.string.invalid_self_phone_number)
+ private val okText = targetContext.getString(android.R.string.ok)
+
+ private var confirmedNumber: String? = null
+ private var wasErrorDismissed = false
+
+ @Test
+ fun dialogShowsTheRejectionItIsGivenWithoutLosingWhatWasTyped() {
+ setContent(currentNumber = "not a number", isInvalid = true)
+
+ composeTestRule.onNodeWithText(errorText).assertIsDisplayed()
+ composeTestRule.onNodeWithText("not a number").assertIsDisplayed()
+ }
+
+ @Test
+ fun dialogShowsNoErrorUntilItIsGivenOne() {
+ setContent(currentNumber = "+37255550001", isInvalid = false)
+
+ composeTestRule.onNodeWithText(errorText).assertIsNotDisplayed()
+ }
+
+ @Test
+ fun dialogHandsTheTypedNumberUpOnConfirm() {
+ setContent(currentNumber = "+37255550001", isInvalid = false)
+
+ composeTestRule.onNodeWithText(okText).performClick()
+
+ composeTestRule.runOnIdle {
+ assertEquals("+37255550001", confirmedNumber)
+ }
+ }
+
+ @Test
+ fun dialogReportsAnEditSoTheRejectionCanBeCleared() {
+ setContent(currentNumber = "12", isInvalid = true)
+
+ composeTestRule.onNodeWithText("12").performTextInput("3")
+
+ composeTestRule.runOnIdle {
+ assertTrue(
+ "the dialog does not decide the error is gone, it reports the edit that clears it",
+ wasErrorDismissed,
+ )
+ }
+ }
+
+ private fun setContent(
+ currentNumber: String,
+ isInvalid: Boolean,
+ ) {
+ composeTestRule.setContent {
+ AppTheme {
+ PhoneNumberDialog(
+ currentNumber = currentNumber,
+ isInvalid = isInvalid,
+ onErrorDismissed = { wasErrorDismissed = true },
+ onDismiss = {},
+ onConfirm = { confirmedNumber = it },
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapperImplTest.kt
new file mode 100644
index 000000000..39b9ab049
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/blockedparticipants/screen/mapper/BlockedParticipantsUiStateMapperImplTest.kt
@@ -0,0 +1,73 @@
+package com.android.messaging.ui.blockedparticipants.screen.mapper
+
+import com.android.messaging.data.blockedparticipants.model.BlockedDirectChat
+import com.android.messaging.data.conversation.model.ConversationId
+import com.android.messaging.datamodel.data.ParticipantData
+import com.android.messaging.ui.blockedparticipants.screen.model.BlockedParticipantUiState
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+internal class BlockedParticipantsUiStateMapperImplTest {
+
+ private val mapper = BlockedParticipantsUiStateMapperImpl(
+ canPlacePhoneCall = { false },
+ canShowOrAddContact = { _, _, _, _ -> false },
+ isContactSavedUseCase = { _, _ -> false },
+ )
+
+ @Test
+ fun map_unsavedNumberWithNullFullName_usesFormattedDestinationAsDisplayName() {
+ val participantUiState = mapParticipant(
+ name = null,
+ unknownSender = true,
+ )
+
+ assertEquals(DISPLAY_DESTINATION, participantUiState.displayName)
+ assertNull(participantUiState.details)
+ }
+
+ @Test
+ fun map_savedContact_usesFullNameAndKeepsFormattedDestinationAsDetails() {
+ val participantUiState = mapParticipant(
+ name = FULL_NAME,
+ unknownSender = false,
+ )
+
+ assertEquals(FULL_NAME, participantUiState.displayName)
+ assertEquals(DISPLAY_DESTINATION, participantUiState.details)
+ }
+
+ private fun mapParticipant(
+ name: String?,
+ unknownSender: Boolean,
+ ): BlockedParticipantUiState {
+ val participant = mockk(relaxed = true) {
+ every { fullName } returns name
+ every { sendDestination } returns SEND_DESTINATION
+ every { displayDestination } returns DISPLAY_DESTINATION
+ every { isUnknownSender } returns unknownSender
+ }
+
+ return mapper
+ .map(
+ persistentListOf(
+ BlockedDirectChat(
+ participant = participant,
+ conversationId = CONVERSATION_ID,
+ ),
+ ),
+ )
+ .single()
+ }
+
+ private companion object {
+ private val CONVERSATION_ID = ConversationId("conversation-1")
+ private const val SEND_DESTINATION = "+15550123"
+ private const val DISPLAY_DESTINATION = "+1 555-0123"
+ private const val FULL_NAME = "Ada Lovelace"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt
index 4fcdc46de..898199c17 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/delegate/draft/ConversationDraftDelegateSendValidationTest.kt
@@ -7,6 +7,7 @@ import com.android.messaging.domain.conversation.usecase.draft.SendConversationD
import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException
import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException
import com.android.messaging.domain.conversation.usecase.draft.exception.MessageLimitExceededException
+import com.android.messaging.domain.conversation.usecase.draft.exception.MissingSelfPhoneNumberForGroupMmsException
import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException
import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException
import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID
@@ -57,6 +58,19 @@ internal class ConversationDraftDelegateSendValidationTest : BaseConversationDra
}
}
+ @Test
+ fun sendValidationFailure_whenSelfPhoneNumberIsUnknown_emitsAddYourPhoneNumberMessage() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ assertSendFailureMessage(
+ exception = MissingSelfPhoneNumberForGroupMmsException(
+ conversationId = CONVERSATION_ID,
+ selfSubId = SubId(1),
+ ),
+ expectedMessageResId = R.string.cant_send_group_mms_without_self_phone_number,
+ )
+ }
+ }
+
@Test
fun sendValidationFailure_whenTooManyVideos_setsVideoLimitWarning() {
runTest(context = mainDispatcherRule.testDispatcher) {
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 8c77386e5..635206d6f 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
@@ -1,5 +1,7 @@
package com.android.messaging.ui.conversation.composer.ui
+import android.view.View
+import android.view.WindowManager
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@@ -55,11 +57,14 @@ import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
+import org.robolectric.shadow.api.Shadow
+import org.robolectric.shadows.ShadowWindowManagerImpl
@RunWith(RobolectricTestRunner::class)
class ConversationComposeBarTest {
@@ -754,6 +759,72 @@ class ConversationComposeBarTest {
}
}
+ /**
+ * Back is delivered to the focused window, so a `FLAG_NOT_FOCUSABLE` menu never sees it and
+ * the press reaches the navigation stack instead, closing the conversation along with the
+ * menu. `FLAG_ALT_FOCUSABLE_IM` is what lets the menu be focusable without taking the keyboard
+ * down with it — the media picker reads the keyboard state to decide whether to restore it.
+ * Robolectric cannot route key events between windows, so the assertions are on the window
+ * flags that behaviour depends on.
+ */
+ @Test
+ fun attachmentMenu_isFocusableForBackButLeavesTheKeyboardUp() {
+ setContent(
+ messageText = "",
+ isSendActionEnabled = false,
+ isAttachmentActionEnabled = true,
+ )
+
+ val menuWindowFlags = openAttachmentMenuAndReadWindowFlags()
+
+ assertEquals(
+ "the attachment menu window must be focusable to receive back",
+ 0,
+ menuWindowFlags and WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
+ )
+ assertNotEquals(
+ "the focused menu must not take the keyboard down with it",
+ 0,
+ menuWindowFlags and WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
+ )
+ assertNotEquals(
+ "the bottom anchored menu must stay unclipped by the window it is anchored in",
+ 0,
+ menuWindowFlags and WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
+ )
+ assertNotEquals(
+ "the menu must keep watching outside touches to stay tap dismissable",
+ 0,
+ menuWindowFlags and WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
+ )
+ }
+
+ private fun openAttachmentMenuAndReadWindowFlags(): Int {
+ val windowsBeforeOpening = addedWindows()
+
+ composeTestRule
+ .onNodeWithTag(
+ testTag = CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG,
+ useUnmergedTree = true,
+ )
+ .performClick()
+ composeTestRule
+ .onNodeWithTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG)
+ .assertExists()
+
+ val menuWindow = addedWindows().single { window ->
+ windowsBeforeOpening.none { openedEarlier -> openedEarlier === window }
+ }
+
+ return (menuWindow.layoutParams as WindowManager.LayoutParams).flags
+ }
+
+ private fun addedWindows(): List {
+ val windowManager = targetContext.getSystemService(WindowManager::class.java)
+
+ return Shadow.extract(windowManager).views.toList()
+ }
+
private fun setContent(
audioRecording: ConversationAudioRecordingUiState = ConversationAudioRecordingUiState(),
messageText: String,
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
index 3934c8a4d..9db76435e 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt
@@ -4,6 +4,7 @@ import androidx.core.net.toUri
import com.android.messaging.data.appsettings.repository.AppSettingsRepository
import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository
import com.android.messaging.data.conversation.repository.ConversationsRepository
+import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis
import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper
import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper
@@ -25,6 +26,7 @@ internal class ConversationMessagesDelegateImplTest {
private val delegate = ConversationMessagesDelegateImpl(
conversationsRepository = mockk(),
appSettingsRepository = mockk(),
+ resolveAudioDurationMillis = mockk(),
resolveInitialPhotoOccurrenceIndex = resolveInitialPhotoOccurrenceIndex,
conversationMessageUiModelMapper = mockk(),
conversationVCardAttachmentUiModelMapper =
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt
index 3c507c7c4..c03e9a46b 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt
@@ -9,6 +9,7 @@ import com.android.messaging.data.conversation.model.attachment.ConversationVCar
import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository
import com.android.messaging.data.conversation.repository.ConversationsRepository
import com.android.messaging.datamodel.data.ConversationMessageData
+import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis
import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
import com.android.messaging.testutil.MainDispatcherRule
import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID
@@ -38,6 +39,8 @@ internal abstract class BaseConversationMessagesDelegateTest {
protected val appSettingsRepository = mockk {
coEvery { isYouTubeLinkPreviewsEnabled() } returns false
}
+ protected val resolveAudioDurationMillis =
+ mockk(relaxed = true)
protected val messageUiModelMapper = mockk()
protected val vCardUiModelMapper = mockk()
protected val vCardMetadataRepository = mockk()
@@ -46,6 +49,7 @@ internal abstract class BaseConversationMessagesDelegateTest {
return ConversationMessagesDelegateImpl(
conversationsRepository = conversationsRepository,
appSettingsRepository = appSettingsRepository,
+ resolveAudioDurationMillis = resolveAudioDurationMillis,
resolveInitialPhotoOccurrenceIndex =
mockk(relaxed = true),
conversationMessageUiModelMapper = messageUiModelMapper,
@@ -154,14 +158,16 @@ internal abstract class BaseConversationMessagesDelegateTest {
}
protected fun audioPart(
- contentUri: String = "content://media/audio/1",
+ contentUri: String? = "content://media/audio/1",
+ durationMillis: Long = 0L,
): ConversationMessagePartUiModel.Attachment.Audio {
return ConversationMessagePartUiModel.Attachment.Audio(
text = null,
contentType = "audio/mpeg",
- contentUri = Uri.parse(contentUri),
+ contentUri = contentUri?.let(Uri::parse),
width = 0,
height = 0,
+ durationMillis = durationMillis,
)
}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateAudioDurationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateAudioDurationTest.kt
new file mode 100644
index 000000000..a85c2a8eb
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateAudioDurationTest.kt
@@ -0,0 +1,139 @@
+package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate
+
+import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID
+import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState
+import io.mockk.coEvery
+import io.mockk.coVerify
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationMessagesDelegateAudioDurationTest :
+ BaseConversationMessagesDelegateTest() {
+
+ @Test
+ fun bind_withAudioPart_seedsTheClipLengthBeforeItIsPlayed() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val audio = audioPart(contentUri = AUDIO_CONTENT_URI)
+ val message = messageUiModel(messageId = "m1", parts = listOf(audio))
+ givenConversationMessages(messages = flowOf(messagesOf(message)))
+ givenAudioDuration(contentUri = AUDIO_CONTENT_URI, durationMillis = DURATION_MILLIS)
+
+ val delegate = createBoundDelegate(
+ conversationIdFlow = MutableStateFlow(CONVERSATION_ID),
+ )
+ runCurrent()
+
+ assertEquals(
+ ConversationMessagesUiState.Present(
+ persistentListOf(
+ message.copy(
+ parts = persistentListOf(
+ audio.copy(durationMillis = DURATION_MILLIS),
+ ),
+ ),
+ ),
+ ),
+ delegate.state.value,
+ )
+ }
+ }
+
+ @Test
+ fun bind_withDuplicateAudioContentUris_resolvesUriOnceAndUpdatesBothParts() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val firstAudio = audioPart(contentUri = AUDIO_CONTENT_URI)
+ val secondAudio = audioPart(contentUri = AUDIO_CONTENT_URI)
+ val first = messageUiModel(messageId = "first", parts = listOf(firstAudio))
+ val second = messageUiModel(messageId = "second", parts = listOf(secondAudio))
+ givenConversationMessages(messages = flowOf(messagesOf(first, second)))
+ givenAudioDuration(contentUri = AUDIO_CONTENT_URI, durationMillis = DURATION_MILLIS)
+
+ val delegate = createBoundDelegate(
+ conversationIdFlow = MutableStateFlow(CONVERSATION_ID),
+ )
+ runCurrent()
+
+ assertEquals(
+ ConversationMessagesUiState.Present(
+ persistentListOf(
+ first.copy(
+ parts = persistentListOf(
+ firstAudio.copy(durationMillis = DURATION_MILLIS),
+ ),
+ ),
+ second.copy(
+ parts = persistentListOf(
+ secondAudio.copy(durationMillis = DURATION_MILLIS),
+ ),
+ ),
+ ),
+ ),
+ delegate.state.value,
+ )
+ coVerify(exactly = 1) {
+ resolveAudioDurationMillis(contentUri = AUDIO_CONTENT_URI)
+ }
+ }
+ }
+
+ @Test
+ fun bind_withoutAudioParts_skipsTheDurationLookup() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val message = messageUiModel(messageId = "m1", parts = listOf(textPart()))
+ givenConversationMessages(messages = flowOf(messagesOf(message)))
+
+ createBoundDelegate(conversationIdFlow = MutableStateFlow(CONVERSATION_ID))
+ runCurrent()
+
+ coVerify(exactly = 0) {
+ resolveAudioDurationMillis(contentUri = any())
+ }
+ }
+ }
+
+ @Test
+ fun bind_withAudioPartWithoutContentUri_leavesTheDurationUnknown() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val audio = audioPart(contentUri = null)
+ val message = messageUiModel(messageId = "m1", parts = listOf(audio))
+ givenConversationMessages(messages = flowOf(messagesOf(message)))
+
+ val delegate = createBoundDelegate(
+ conversationIdFlow = MutableStateFlow(CONVERSATION_ID),
+ )
+ runCurrent()
+
+ assertEquals(
+ ConversationMessagesUiState.Present(persistentListOf(message)),
+ delegate.state.value,
+ )
+ coVerify(exactly = 0) {
+ resolveAudioDurationMillis(contentUri = any())
+ }
+ }
+ }
+
+ private fun givenAudioDuration(
+ contentUri: String,
+ durationMillis: Long,
+ ) {
+ coEvery {
+ resolveAudioDurationMillis(contentUri = contentUri)
+ } returns durationMillis
+ }
+
+ private companion object {
+ private const val AUDIO_CONTENT_URI = "content://mms/part/audio-1"
+ private const val DURATION_MILLIS = 18_000L
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt
index 29eb3e880..8c2ec1342 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperMappingTest.kt
@@ -61,7 +61,7 @@ internal class ConversationMessageUiModelMapperMappingTest :
parts = persistentListOf(),
sentTimestamp = 1_000L,
receivedTimestamp = 2_000L,
- displayTimestamp = 1_000L,
+ displayTimestamp = 2_000L,
status = Status.Outgoing.Delivered,
isIncoming = false,
senderDisplayName = "Ada Lovelace",
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt
index 1585e0a5d..1d2f103e8 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/mapper/conversationmessage/ConversationMessageUiModelMapperProtocolAndTimestampTest.kt
@@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.messages.mapper.conversationmessag
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Protocol
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@@ -65,16 +66,34 @@ internal class ConversationMessageUiModelMapperProtocolAndTimestampTest :
}
@Test
- fun map_outgoingMessage_usesSentTimestampAsDisplayTimestamp() {
+ fun map_outgoingMessage_usesReceivedTimestampAsDisplayTimestamp() {
val uiModel = mapPresent(
messageData(isIncoming = false, sentTimestamp = 300L, receivedTimestamp = 900L),
)
+ assertEquals(900L, uiModel.displayTimestamp)
+ }
+
+ @Test
+ fun map_outgoingMessageWithoutReceivedTimestamp_fallsBackToSentTimestamp() {
+ val uiModel = mapPresent(
+ messageData(isIncoming = false, sentTimestamp = 300L, receivedTimestamp = 0L),
+ )
+
+ assertEquals(300L, uiModel.displayTimestamp)
+ }
+
+ @Test
+ fun map_outgoingMessageWithNegativeReceivedTimestamp_fallsBackToSentTimestamp() {
+ val uiModel = mapPresent(
+ messageData(isIncoming = false, sentTimestamp = 300L, receivedTimestamp = -1L),
+ )
+
assertEquals(300L, uiModel.displayTimestamp)
}
@Test
- fun map_outgoingMessageWithoutSentTimestamp_fallsBackToReceivedTimestamp() {
+ fun map_outgoingMessageWithoutSentTimestamp_stillUsesReceivedTimestamp() {
val uiModel = mapPresent(
messageData(isIncoming = false, sentTimestamp = 0L, receivedTimestamp = 900L),
)
@@ -83,11 +102,35 @@ internal class ConversationMessageUiModelMapperProtocolAndTimestampTest :
}
@Test
- fun map_outgoingMessageWithNegativeSentTimestamp_fallsBackToReceivedTimestamp() {
+ fun map_outgoingMessageSentAfterItWasReceived_stillLabelsWithReceivedTimestamp() {
val uiModel = mapPresent(
- messageData(isIncoming = false, sentTimestamp = -1L, receivedTimestamp = 900L),
+ messageData(isIncoming = false, sentTimestamp = 9_000L, receivedTimestamp = 5_000L),
)
- assertEquals(900L, uiModel.displayTimestamp)
+ assertEquals(5_000L, uiModel.displayTimestamp)
+ }
+
+ @Test
+ fun map_retriedOutgoingMessage_labelsWithTheTimestampTheThreadIsSortedBy() {
+ val earlier = mapPresent(
+ messageData(
+ isIncoming = false,
+ receivedTimestamp = 1_000L,
+ sentTimestamp = 1_000L,
+ ),
+ )
+ val retried = mapPresent(
+ messageData(
+ isIncoming = false,
+ receivedTimestamp = 5_000L,
+ sentTimestamp = 900L,
+ ),
+ )
+
+ assertEquals(5_000L, retried.displayTimestamp)
+ assertTrue(
+ "a message sorted below another must never be labelled earlier than it",
+ retried.displayTimestamp > earlier.displayTimestamp,
+ )
}
}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowDurationTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowDurationTest.kt
new file mode 100644
index 000000000..7c118b364
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRowDurationTest.kt
@@ -0,0 +1,68 @@
+package com.android.messaging.ui.conversation.messages.ui.attachment
+
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS
+import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment
+import com.android.messaging.ui.core.AppTheme
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationInlineAudioAttachmentRowDurationTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun idleRow_showsTheResolvedDurationWithoutPlayingItFirst() {
+ setContent(durationMillis = ROW_AUDIO_DURATION_MILLIS)
+
+ composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) {
+ composeTestRule
+ .onAllNodesWithText(text = "00:18")
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ }
+
+ @Test
+ fun idleRow_withUnknownDuration_fallsBackToZero() {
+ setContent(durationMillis = 0L)
+
+ composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) {
+ composeTestRule
+ .onAllNodesWithText(text = "00:00")
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+ }
+
+ private fun setContent(durationMillis: Long) {
+ composeTestRule.setContent {
+ AppTheme {
+ ConversationInlineAudioAttachmentRow(
+ attachment = ConversationInlineAttachment.Audio(
+ key = "audio-key",
+ contentUri = ROW_AUDIO_CONTENT_URI,
+ openAction = null,
+ titleText = "Audio attachment",
+ titleTextResId = null,
+ durationMillis = durationMillis,
+ ),
+ isIncoming = true,
+ isSelectionMode = false,
+ useStandaloneAudioAttachmentBackground = false,
+ onLongClick = {},
+ )
+ }
+ }
+ }
+
+ private companion object {
+ private const val ROW_AUDIO_CONTENT_URI = "content://mms/part/row-audio"
+ private const val ROW_AUDIO_DURATION_MILLIS = 18_000L
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt
index f22a9c2a0..571c802a5 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationInlineAudioAttachmentPlaybackStateTest.kt
@@ -4,6 +4,7 @@ import android.media.MediaPlayer
import com.android.common.test.helpers.targetContext
import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationInlineAudioAttachmentPlaybackState
import io.mockk.mockk
+import io.mockk.unmockkAll
import io.mockk.verify
import java.io.IOException
import org.junit.After
@@ -21,6 +22,7 @@ private const val STATE_AUDIO_CONTENT_URI = "content://mms/part/test-audio"
private const val STATE_MISSING_AUDIO_CONTENT_URI = "content://mms/part/missing-audio"
private const val STATE_AUDIO_DURATION_MILLIS = 18_000
private const val STATE_PAUSED_POSITION_MILLIS = 4_500
+private const val STATE_SEEDED_DURATION_MILLIS = 42_000L
@RunWith(RobolectricTestRunner::class)
internal class ConversationInlineAudioAttachmentPlaybackStateTest {
@@ -40,6 +42,7 @@ internal class ConversationInlineAudioAttachmentPlaybackStateTest {
@After
fun tearDown() {
ShadowMediaPlayer.resetStaticState()
+ unmockkAll()
}
@Test
@@ -56,6 +59,43 @@ internal class ConversationInlineAudioAttachmentPlaybackStateTest {
}
}
+ @Test
+ fun seededDuration_reportsMediaLengthWithoutPlayingIt() {
+ val playbackState = playbackState(initialDurationMillis = STATE_SEEDED_DURATION_MILLIS)
+
+ assertEquals(STATE_SEEDED_DURATION_MILLIS, playbackState.durationMillis)
+ assertEquals("00:42", playbackState.durationLabel)
+ assertFalse(playbackState.isPlaying)
+ }
+
+ @Test
+ fun seededDuration_isReplacedByThePreparedPlayerDuration() {
+ val playbackState = startedPlaybackState(
+ initialDurationMillis = STATE_SEEDED_DURATION_MILLIS,
+ )
+
+ assertEquals(STATE_AUDIO_DURATION_MILLIS.toLong(), playbackState.durationMillis)
+ }
+
+ @Test
+ fun seedDuration_appliesALateResolvedLength() {
+ val playbackState = playbackState()
+
+ playbackState.seedDurationMillis(STATE_SEEDED_DURATION_MILLIS)
+
+ assertEquals(STATE_SEEDED_DURATION_MILLIS, playbackState.durationMillis)
+ assertEquals("00:42", playbackState.durationLabel)
+ }
+
+ @Test
+ fun seedDuration_neverOverridesThePreparedPlayerDuration() {
+ val playbackState = startedPlaybackState()
+
+ playbackState.seedDurationMillis(STATE_SEEDED_DURATION_MILLIS)
+
+ assertEquals(STATE_AUDIO_DURATION_MILLIS.toLong(), playbackState.durationMillis)
+ }
+
@Test
fun togglePlayback_beforePreparedQueuesStartUntilPrepared() {
addAudioMediaInfo(preparationDelayMillis = -1)
@@ -185,12 +225,12 @@ internal class ConversationInlineAudioAttachmentPlaybackStateTest {
}
@Test
- fun mediaError_reportsFailureAndResetsPlaybackState() {
+ fun mediaError_reportsFailureAndKeepsTheKnownDuration() {
val playbackState = startedPlaybackState()
shadowMediaPlayer.invokeErrorListener(MediaPlayer.MEDIA_ERROR_UNKNOWN, 0)
- assertEquals(0L, playbackState.durationMillis)
+ assertEquals(STATE_AUDIO_DURATION_MILLIS.toLong(), playbackState.durationMillis)
assertEquals(0L, playbackState.positionMillis)
assertEquals(0f, playbackState.progress)
assertFalse(playbackState.isPlaying)
@@ -223,15 +263,20 @@ internal class ConversationInlineAudioAttachmentPlaybackStateTest {
}
}
- private fun playbackState(): ConversationInlineAudioAttachmentPlaybackState {
+ private fun playbackState(
+ initialDurationMillis: Long = 0L,
+ ): ConversationInlineAudioAttachmentPlaybackState {
return ConversationInlineAudioAttachmentPlaybackState(
+ initialDurationMillis = initialDurationMillis,
onPlaybackFailure = onPlaybackFailure,
)
}
- private fun startedPlaybackState(): ConversationInlineAudioAttachmentPlaybackState {
+ private fun startedPlaybackState(
+ initialDurationMillis: Long = 0L,
+ ): ConversationInlineAudioAttachmentPlaybackState {
addAudioMediaInfo(preparationDelayMillis = -1)
- val playbackState = playbackState()
+ val playbackState = playbackState(initialDurationMillis = initialDurationMillis)
playbackState.togglePlayback(
context = targetContext,
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt
index 8631e7383..e463b1019 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/dialogs/ConversationScreenDeleteDialogsTest.kt
@@ -58,6 +58,19 @@ internal class ConversationScreenDeleteDialogsTest : BaseConversationScreenDialo
}
}
+ @Test
+ fun deleteConversation_warnsThatItCannotBeUndone() {
+ setDialogsContent(
+ uiState = createDialogUiState(
+ isDeleteConversationConfirmationVisible = true,
+ ),
+ )
+
+ composeTestRule
+ .onNodeWithText(text(R.string.delete_message_confirmation_dialog_text))
+ .assertIsDisplayed()
+ }
+
@Test
fun deleteMessages_multiMessageUsesPluralAndForwardsCallbacks() {
val messageIds = persistentSetOf(
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
new file mode 100644
index 000000000..d60b240e5
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListSelectionTopAppBarTest.kt
@@ -0,0 +1,73 @@
+package com.android.messaging.ui.conversationlist.chats
+
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.R
+import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action
+import com.android.messaging.ui.conversationlist.chats.model.SelectionActionsUiState
+import com.android.messaging.ui.core.AppTheme
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * The read toggle is the one whose label and action have to move together: a selection that is not
+ * entirely read must offer *mark as read*, and tapping that must not mark the selection unread.
+ */
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationListSelectionTopAppBarTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val emittedActions = mutableListOf()
+
+ @Test
+ fun aSelectionThatIsNotEntirelyReadOffersToMarkItRead() {
+ setSelectionContent(allSelectedAreRead = false)
+
+ openOverflowMenu()
+ composeTestRule.onNodeWithText(string(R.string.mark_as_read)).performClick()
+
+ assertEquals(listOf(Action.MarkReadClicked), emittedActions)
+ }
+
+ @Test
+ fun anEntirelyReadSelectionOffersToMarkItUnread() {
+ setSelectionContent(allSelectedAreRead = true)
+
+ openOverflowMenu()
+ composeTestRule.onNodeWithText(string(R.string.mark_as_unread)).performClick()
+
+ assertEquals(listOf(Action.MarkUnreadClicked), emittedActions)
+ }
+
+ private fun setSelectionContent(allSelectedAreRead: Boolean) {
+ composeTestRule.setContent {
+ AppTheme {
+ ConversationListSelectionTopAppBar(
+ selectedCount = 2,
+ actions = SelectionActionsUiState(allSelectedAreRead = allSelectedAreRead),
+ onAction = { action -> emittedActions += action },
+ onDeleteClick = {},
+ onSnoozeClick = {},
+ )
+ }
+ }
+ }
+
+ private fun openOverflowMenu() {
+ composeTestRule
+ .onNodeWithContentDescription(string(R.string.more_options))
+ .performClick()
+ }
+
+ private fun string(resId: Int): String {
+ return targetContext.getString(resId)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListUiStateMapperImplTest.kt
index b8acfa3f4..ba42460b3 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListUiStateMapperImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/mapper/ConversationListUiStateMapperImplTest.kt
@@ -5,12 +5,14 @@ import com.android.messaging.R
import com.android.messaging.data.conversation.model.ConversationId
import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus
import com.android.messaging.data.conversationlist.model.ConversationListSnapshot
+import com.android.messaging.data.phone.formatter.PhoneNumberFormatter
import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri
import com.android.messaging.domain.conversation.usecase.participant.CanAddContact
import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact
import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved
import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall
import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState
+import com.android.messaging.ui.conversationlist.chats.model.SelectionActionsUiState
import com.android.messaging.ui.conversationlist.conversationItem
import com.android.messaging.ui.conversationlist.mapper.ConversationListContentUiStateMapperImpl
import com.android.messaging.ui.conversationlist.mapper.ConversationListItemUiMapperImpl
@@ -24,6 +26,7 @@ import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toPersistentList
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -38,6 +41,7 @@ internal class ConversationListUiStateMapperImplTest {
private val canPlacePhoneCall = mockk(relaxed = true)
private val canShowOrAddContact = mockk(relaxed = true)
private val isContactSaved = mockk(relaxed = true)
+ private val phoneNumberFormatter = mockk(relaxed = true)
private val resolveAvatarUri = mockk(relaxed = true)
private val itemUiMapper = ConversationListItemUiMapperImpl(
@@ -45,6 +49,7 @@ internal class ConversationListUiStateMapperImplTest {
canPlacePhoneCall = canPlacePhoneCall,
canShowOrAddContact = canShowOrAddContact,
isContactSaved = isContactSaved,
+ phoneNumberFormatter = phoneNumberFormatter,
resolveAvatarUri = resolveAvatarUri,
)
@@ -116,9 +121,9 @@ internal class ConversationListUiStateMapperImplTest {
val actions = state.selection.actions
assertEquals(0, state.selection.selectedCount)
- assertNull(actions.firstSelectedIsPinned)
- assertNull(actions.firstSelectedIsSnoozed)
- assertNull(actions.firstSelectedIsUnread)
+ assertNull(actions.allSelectedArePinned)
+ assertNull(actions.allSelectedAreSnoozed)
+ assertNull(actions.allSelectedAreRead)
}
@Test
@@ -139,38 +144,52 @@ internal class ConversationListUiStateMapperImplTest {
)
val actions = state.selection.actions
- assertTrue(requireNotNull(actions.firstSelectedIsPinned))
- assertTrue(requireNotNull(actions.firstSelectedIsSnoozed))
- assertTrue(requireNotNull(actions.firstSelectedIsUnread))
+ assertTrue(requireNotNull(actions.allSelectedArePinned))
+ assertTrue(requireNotNull(actions.allSelectedAreSnoozed))
+ assertFalse(requireNotNull(actions.allSelectedAreRead))
}
@Test
- fun map_mixedSelection_togglesFollowFirstSelectedConversation() {
- val state = mapper.map(
- snapshot = snapshotOf(
- conversationItem(
- conversationId = ConversationId("first"),
- isPinned = false,
- isSnoozed = false,
- ),
- conversationItem(
- conversationId = ConversationId("second"),
- isPinned = true,
- isSnoozed = true,
- ),
+ fun map_mixedSelection_offersTheSameTogglesWhicheverRowWasTappedFirst() {
+ val snapshot = mixedSnapshot()
+
+ val plainTappedFirst = selectionActions(snapshot, "plain", "marked")
+ val markedTappedFirst = selectionActions(snapshot, "marked", "plain")
+
+ assertEquals(plainTappedFirst, markedTappedFirst)
+ }
+
+ @Test
+ fun map_mixedSelection_offersToPinSnoozeAndMarkTheWholeSelectionRead() {
+ val actions = selectionActions(mixedSnapshot(), "marked", "plain")
+
+ assertFalse(requireNotNull(actions.allSelectedArePinned))
+ assertFalse(requireNotNull(actions.allSelectedAreSnoozed))
+ assertFalse(requireNotNull(actions.allSelectedAreRead))
+ }
+
+ @Test
+ fun map_selectionThatIsEntirelyPinnedSnoozedAndRead_offersTheOppositeActions() {
+ val snapshot = snapshotOf(
+ conversationItem(
+ conversationId = ConversationId("a"),
+ isPinned = true,
+ isSnoozed = true,
+ isRead = true,
),
- selectedConversationIds = persistentListOf(
- ConversationId("first"),
- ConversationId("second")
+ conversationItem(
+ conversationId = ConversationId("b"),
+ isPinned = true,
+ isSnoozed = true,
+ isRead = true,
),
- openedConversationId = null,
- isScrollToTopVisible = false,
- isDebugEnabled = false,
)
- val actions = state.selection.actions
- assertFalse(requireNotNull(actions.firstSelectedIsPinned))
- assertFalse(requireNotNull(actions.firstSelectedIsSnoozed))
+ val actions = selectionActions(snapshot, "a", "b")
+
+ assertTrue(requireNotNull(actions.allSelectedArePinned))
+ assertTrue(requireNotNull(actions.allSelectedAreSnoozed))
+ assertTrue(requireNotNull(actions.allSelectedAreRead))
}
@Test
@@ -323,6 +342,36 @@ internal class ConversationListUiStateMapperImplTest {
assertFalse(state.selection.actions.canBlock)
}
+ private fun mixedSnapshot(): ConversationListSnapshot {
+ return snapshotOf(
+ conversationItem(
+ conversationId = ConversationId("plain"),
+ isPinned = false,
+ isSnoozed = false,
+ isRead = true,
+ ),
+ conversationItem(
+ conversationId = ConversationId("marked"),
+ isPinned = true,
+ isSnoozed = true,
+ isRead = false,
+ ),
+ )
+ }
+
+ private fun selectionActions(
+ snapshot: ConversationListSnapshot,
+ vararg selectedIds: String,
+ ): SelectionActionsUiState {
+ return mapper.map(
+ snapshot = snapshot,
+ selectedConversationIds = selectedIds.map(::ConversationId).toPersistentList(),
+ openedConversationId = null,
+ isScrollToTopVisible = false,
+ isDebugEnabled = false,
+ ).selection.actions
+ }
+
private fun singleItem(
state: ConversationListUiState,
): ConversationListItemUiModel {
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/dialog/ConversationListDeleteDialogTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/dialog/ConversationListDeleteDialogTest.kt
new file mode 100644
index 000000000..cf16fd31e
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/dialog/ConversationListDeleteDialogTest.kt
@@ -0,0 +1,57 @@
+package com.android.messaging.ui.conversationlist.common.dialog
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.R
+import com.android.messaging.ui.core.AppTheme
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+/**
+ * Deleting a conversation is irreversible and is not covered by the archive undo snackbar, so it
+ * has to carry at least the warning that deleting a single message already carries.
+ */
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationListDeleteDialogTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun deletingOneConversationWarnsThatItCannotBeUndone() {
+ setDeleteDialogContent(selectedCount = 1)
+
+ composeTestRule
+ .onNodeWithText(string(R.string.delete_message_confirmation_dialog_text))
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun deletingSeveralConversationsWarnsThatItCannotBeUndone() {
+ setDeleteDialogContent(selectedCount = 3)
+
+ composeTestRule
+ .onNodeWithText(string(R.string.delete_message_confirmation_dialog_text))
+ .assertIsDisplayed()
+ }
+
+ private fun setDeleteDialogContent(selectedCount: Int) {
+ composeTestRule.setContent {
+ AppTheme {
+ ConversationListDeleteDialog(
+ selectedCount = selectedCount,
+ onConfirm = {},
+ onDismiss = {},
+ )
+ }
+ }
+ }
+
+ private fun string(resId: Int): String {
+ return targetContext.getString(resId)
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/list/ConversationListItemsTraversalOrderTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/list/ConversationListItemsTraversalOrderTest.kt
new file mode 100644
index 000000000..73b6df789
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/common/list/ConversationListItemsTraversalOrderTest.kt
@@ -0,0 +1,133 @@
+package com.android.messaging.ui.conversationlist.common.list
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.getOrNull
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.unit.dp
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.data.conversation.model.ConversationId
+import com.android.messaging.testutil.installTestFactory
+import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeKind
+import com.android.messaging.ui.conversationlist.common.support.conversationListItemTestTag
+import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem
+import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel
+import com.android.messaging.ui.core.AppTheme
+import kotlinx.collections.immutable.persistentSetOf
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ConversationListItemsTraversalOrderTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Before
+ fun setUp() {
+ installTestFactory(context = targetContext)
+ }
+
+ @After
+ fun tearDown() {
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun rowsAreTraversedInListOrderWhenNothingIsPinned() {
+ val items = listOf(
+ item(conversationId = "a"),
+ item(conversationId = "b"),
+ item(conversationId = "c"),
+ )
+
+ setListContent(items)
+
+ assertEquals(tagsOf(items), traversedRowTags(items))
+ }
+
+ @Test
+ fun pinnedRowIsTraversedFirstJustAsItIsRenderedFirst() {
+ val items = listOf(
+ item(conversationId = "c", isPinned = true),
+ item(conversationId = "a"),
+ item(conversationId = "b"),
+ )
+
+ setListContent(items)
+
+ assertEquals(tagsOf(items), traversedRowTags(items))
+ }
+
+ @Test
+ fun pinnedRowsKeepTheirOrderAmongThemselvesAndStayAheadOfTheRest() {
+ val items = listOf(
+ item(conversationId = "c", isPinned = true),
+ item(conversationId = "b", isPinned = true),
+ item(conversationId = "a"),
+ )
+
+ setListContent(items)
+
+ assertEquals(tagsOf(items), traversedRowTags(items))
+ }
+
+ private fun setListContent(items: List) {
+ composeTestRule.setContent {
+ AppTheme {
+ ConversationListItems(
+ items = items.toImmutableList(),
+ restoredConversationIds = persistentSetOf(),
+ listState = rememberLazyListState(),
+ isSelectionMode = false,
+ scaffoldContentPadding = PaddingValues(),
+ fabBottomReserve = 0.dp,
+ pinAnimationController = null,
+ swipeSpec = ConversationListSwipeSpec(
+ startToEnd = ConversationSwipeKind.ToggleRead,
+ endToStart = ConversationSwipeKind.Archive,
+ ),
+ onItemEvent = {},
+ )
+ }
+ }
+ }
+
+ /** The row test tags in the order the semantics tree hands them out. */
+ private fun traversedRowTags(items: List): List {
+ val rowTags = tagsOf(items).toSet()
+ val isConversationRow = SemanticsMatcher("is a conversation row") { node ->
+ node.config.getOrNull(SemanticsProperties.TestTag) in rowTags
+ }
+
+ return composeTestRule
+ .onAllNodes(isConversationRow)
+ .fetchSemanticsNodes()
+ .map { node -> node.config[SemanticsProperties.TestTag] }
+ }
+
+ private fun tagsOf(items: List): List {
+ return items.map { item -> conversationListItemTestTag(item.conversationId) }
+ }
+
+ private fun item(
+ conversationId: String,
+ isPinned: Boolean = false,
+ ): ConversationListItemUiModel {
+ return previewConversationListItem(
+ conversationId = ConversationId(conversationId),
+ title = conversationId,
+ snippetText = conversationId,
+ isPinned = isPinned,
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListContentUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListContentUiStateMapperImplTest.kt
index ac8774574..e8a34365b 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListContentUiStateMapperImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListContentUiStateMapperImplTest.kt
@@ -3,6 +3,7 @@ package com.android.messaging.ui.conversationlist.mapper
import android.content.Context
import com.android.messaging.data.conversation.model.ConversationId
import com.android.messaging.data.conversationlist.model.ConversationListSnapshot
+import com.android.messaging.data.phone.formatter.PhoneNumberFormatter
import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri
import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact
import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved
@@ -41,6 +42,7 @@ internal class ConversationListContentUiStateMapperImplTest {
canPlacePhoneCall = mockk(relaxed = true),
canShowOrAddContact = mockk(relaxed = true),
isContactSaved = mockk(relaxed = true),
+ phoneNumberFormatter = mockk(relaxed = true),
resolveAvatarUri = mockk(relaxed = true),
)
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListItemUiMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListItemUiMapperImplTest.kt
new file mode 100644
index 000000000..075cf4cb3
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/mapper/ConversationListItemUiMapperImplTest.kt
@@ -0,0 +1,93 @@
+package com.android.messaging.ui.conversationlist.mapper
+
+import android.content.Context
+import com.android.messaging.data.conversation.model.ConversationId
+import com.android.messaging.data.phone.formatter.PhoneNumberFormatter
+import com.android.messaging.domain.conversation.usecase.avatar.ResolveAvatarUri
+import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact
+import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved
+import com.android.messaging.domain.conversation.usecase.telephony.CanPlacePhoneCall
+import com.android.messaging.ui.conversationlist.conversationItem
+import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel
+import com.android.messaging.util.OsUtil
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+
+internal class ConversationListItemUiMapperImplTest {
+
+ private val phoneNumberFormatter = mockk {
+ every { formatForDisplay(NORMALIZED_DESTINATION) } returns DISPLAY_DESTINATION
+ }
+
+ private val mapper = ConversationListItemUiMapperImpl(
+ context = mockk(relaxed = true),
+ canPlacePhoneCall = mockk(relaxed = true),
+ canShowOrAddContact = mockk(relaxed = true),
+ isContactSaved = mockk(relaxed = true),
+ phoneNumberFormatter = phoneNumberFormatter,
+ resolveAvatarUri = mockk(relaxed = true),
+ )
+
+ @Before
+ fun setUp() {
+ mockkStatic(OsUtil::class)
+ every { OsUtil.isSecondaryUser() } returns false
+ }
+
+ @After
+ fun tearDown() {
+ unmockkStatic(OsUtil::class)
+ }
+
+ @Test
+ fun map_oneOnOne_formatsSubtitleAndKeepsNormalizedDestinationCanonical() {
+ val avatar = mapAvatar(isGroup = false)
+
+ assertEquals(DISPLAY_DESTINATION, avatar.subtitle)
+ assertEquals(NORMALIZED_DESTINATION, avatar.normalizedDestination)
+ }
+
+ @Test
+ fun map_group_hasNoSubtitle() {
+ assertNull(mapAvatar(isGroup = true).subtitle)
+ }
+
+ @Test
+ fun map_unsavedNumberTitledByItsOwnDestination_hasNoSubtitle() {
+ assertNull(mapAvatar(isGroup = false, title = DISPLAY_DESTINATION).subtitle)
+ }
+
+ private fun mapAvatar(
+ isGroup: Boolean,
+ title: String? = null,
+ ): ConversationListAvatarUiModel {
+ val item = conversationItem(conversationId = CONVERSATION_ID)
+
+ return mapper
+ .map(
+ item = item.copy(
+ title = title ?: item.title,
+ participant = item.participant.copy(
+ otherNormalizedDestination = NORMALIZED_DESTINATION,
+ isGroup = isGroup,
+ ),
+ ),
+ isSelected = false,
+ isOpened = false,
+ )
+ .avatar
+ }
+
+ private companion object {
+ private val CONVERSATION_ID = ConversationId("conversation-1")
+ private const val NORMALIZED_DESTINATION = "+15550123"
+ private const val DISPLAY_DESTINATION = "+1 555-0123"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapperImplTest.kt
index 1f6eab42d..ab4bf3da3 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapperImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapperImplTest.kt
@@ -20,25 +20,25 @@ internal class ConversationSettingsUiStateMapperImplTest {
)
@Test
- fun map_unsavedNumberWithNullFullName_usesDestinationAsDisplayName() {
+ fun map_unsavedNumberWithNullFullName_usesFormattedDestinationAsDisplayName() {
val participantUiState = mapParticipant(
name = null,
unknownSender = true,
)
- assertEquals(DESTINATION, participantUiState.displayName)
+ assertEquals(DISPLAY_DESTINATION, participantUiState.displayName)
assertNull(participantUiState.details)
}
@Test
- fun map_savedContact_usesFullNameAndKeepsDestinationAsDetails() {
+ fun map_savedContact_usesFullNameAndKeepsFormattedDestinationAsDetails() {
val participantUiState = mapParticipant(
name = FULL_NAME,
unknownSender = false,
)
assertEquals(FULL_NAME, participantUiState.displayName)
- assertEquals(DESTINATION, participantUiState.details)
+ assertEquals(DISPLAY_DESTINATION, participantUiState.details)
}
private fun mapParticipant(
@@ -47,7 +47,8 @@ internal class ConversationSettingsUiStateMapperImplTest {
): ParticipantUiState {
val participant = mockk(relaxed = true) {
every { fullName } returns name
- every { sendDestination } returns DESTINATION
+ every { sendDestination } returns SEND_DESTINATION
+ every { displayDestination } returns DISPLAY_DESTINATION
every { isUnknownSender } returns unknownSender
}
@@ -64,7 +65,8 @@ internal class ConversationSettingsUiStateMapperImplTest {
private companion object {
private val CONVERSATION_ID = ConversationId("conversation-1")
- private const val DESTINATION = "+15550123"
+ private const val SEND_DESTINATION = "+15550123"
+ private const val DISPLAY_DESTINATION = "+1 555-0123"
private const val FULL_NAME = "Ada Lovelace"
}
}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/recipientselection/component/row/RecipientSelectionContentEmptyStateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/recipientselection/component/row/RecipientSelectionContentEmptyStateTest.kt
new file mode 100644
index 000000000..2260849e4
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/recipientselection/component/row/RecipientSelectionContentEmptyStateTest.kt
@@ -0,0 +1,121 @@
+package com.android.messaging.ui.recipientselection.component.row
+
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.R
+import com.android.messaging.ui.core.AppTheme
+import com.android.messaging.ui.recipientselection.component.RecipientSelectionContactsContent
+import com.android.messaging.ui.recipientselection.model.picker.RecipientPickerUiState
+import com.android.messaging.ui.recipientselection.model.selection.RecipientSelectionContentUiState
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class RecipientSelectionContentEmptyStateTest :
+ BaseRecipientSelectionContactRowTest() {
+
+ private val hostPrompt = targetContext.getString(R.string.forward_picker_empty_text)
+ private val noResultsText = targetContext.getString(R.string.recipient_picker_no_results_text)
+
+ @Test
+ fun emptyContent_withoutQuery_showsTheHostSuppliedPrompt() {
+ setSelectionContent(
+ uiState = emptyContentUiState(query = ""),
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+
+ composeTestRule.onNodeWithText(hostPrompt).assertIsDisplayed()
+ composeTestRule.onNodeWithText(noResultsText).assertDoesNotExist()
+ }
+
+ @Test
+ fun emptyContent_withBlankQuery_showsTheHostSuppliedPrompt() {
+ setSelectionContent(
+ uiState = emptyContentUiState(query = " "),
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+
+ composeTestRule.onNodeWithText(hostPrompt).assertIsDisplayed()
+ composeTestRule.onNodeWithText(noResultsText).assertDoesNotExist()
+ }
+
+ @Test
+ fun emptyContent_withQuery_showsNoResultsInsteadOfThePrompt() {
+ setSelectionContent(
+ uiState = emptyContentUiState(query = "zzqqxx"),
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+
+ composeTestRule.onNodeWithText(noResultsText).assertIsDisplayed()
+ composeTestRule.onNodeWithText(hostPrompt).assertDoesNotExist()
+ }
+
+ @Test
+ fun loadingContent_withQuery_showsNeitherEmptyState() {
+ setSelectionContent(
+ uiState = emptyContentUiState(query = "zzqqxx", isLoading = true),
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+
+ composeTestRule.onNodeWithText(noResultsText).assertDoesNotExist()
+ composeTestRule.onNodeWithText(hostPrompt).assertDoesNotExist()
+ }
+
+ @Test
+ fun matchedContent_withQuery_showsNeitherEmptyState() {
+ setSelectionContent(
+ uiState = RecipientSelectionContentUiState(
+ picker = RecipientPickerUiState(
+ query = "ada",
+ items = persistentListOf(singleDestinationContactItem()),
+ ),
+ ),
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+
+ composeTestRule.onNodeWithText(noResultsText).assertDoesNotExist()
+ composeTestRule.onNodeWithText(hostPrompt).assertDoesNotExist()
+ }
+
+ @Test
+ fun emptyContent_whenQueryIsTyped_swapsThePromptForNoResults() {
+ val uiState = mutableStateOf(emptyContentUiState(query = ""))
+
+ composeTestRule.setContent {
+ AppTheme {
+ RecipientSelectionContactsContent(
+ uiState = uiState.value,
+ rowDecorators = defaultRowDecorators(),
+ onRecipientDestinationClick = onContentDestinationClick,
+ onLoadMore = onLoadMore,
+ onPrimaryActionClick = onPrimaryActionClick,
+ onRecipientDestinationLongClick = onContentDestinationLongClick,
+ emptyStateText = R.string.forward_picker_empty_text,
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText(hostPrompt).assertIsDisplayed()
+
+ uiState.value = emptyContentUiState(query = "zzqqxx")
+
+ composeTestRule.onNodeWithText(noResultsText).assertIsDisplayed()
+ composeTestRule.onNodeWithText(hostPrompt).assertDoesNotExist()
+ }
+
+ private fun emptyContentUiState(
+ query: String,
+ isLoading: Boolean = false,
+ ): RecipientSelectionContentUiState {
+ return RecipientSelectionContentUiState(
+ picker = RecipientPickerUiState(
+ query = query,
+ isLoading = isLoading,
+ ),
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/util/PhoneUtilsSelfNumberTest.kt b/app/src/test/kotlin/com/android/messaging/util/PhoneUtilsSelfNumberTest.kt
new file mode 100644
index 000000000..6d962eeb8
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/util/PhoneUtilsSelfNumberTest.kt
@@ -0,0 +1,197 @@
+package com.android.messaging.util
+
+import android.content.Context
+import android.telephony.SubscriptionManager
+import com.android.messaging.FactoryTestAccess
+import com.android.messaging.R
+import com.android.messaging.testutil.FakeBuglePrefs
+import com.android.messaging.testutil.installTestFactory
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowSubscriptionManager
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class PhoneUtilsSelfNumberTest {
+
+ private lateinit var context: Context
+ private lateinit var prefs: FakeBuglePrefs
+
+ @Before
+ fun setUp() {
+ ShadowSubscriptionManager.reset()
+ context = RuntimeEnvironment.getApplication().applicationContext
+ prefs = FakeBuglePrefs()
+ installTestFactory(context = context, prefs = prefs)
+ }
+
+ @After
+ fun tearDown() {
+ ShadowSubscriptionManager.reset()
+ FactoryTestAccess.reset()
+ }
+
+ @Test
+ fun getSelfRawNumberResolvesTheNumberTheCarrierKnowsWhenTheSimDoesNotCarryIt() {
+ givenSubscription(numberOnSim = "")
+ givenCarrierKnowsNumber(CARRIER_NUMBER)
+
+ assertEquals(
+ "the self number is read from the SIM alone, so carriers that do not write the" +
+ " MSISDN to the UICC leave it unresolvable",
+ CARRIER_NUMBER,
+ PhoneUtils(SUB_ID).getSelfRawNumber(false),
+ )
+ }
+
+ @Test
+ fun getSelfRawNumberPrefersTheNumberTheUserTypedIn() {
+ givenSubscription(numberOnSim = SIM_NUMBER)
+ givenCarrierKnowsNumber(CARRIER_NUMBER)
+ prefs.putString(context.getString(R.string.mms_phone_number_pref_key), OVERRIDE_NUMBER)
+
+ assertEquals(
+ "the number the user entered in settings must win over anything telephony reports",
+ OVERRIDE_NUMBER,
+ PhoneUtils(SUB_ID).getSelfRawNumber(true),
+ )
+ }
+
+ @Test
+ fun getSelfRawNumberFallsBackToTheSimWhenTheCarrierKnowsNoNumber() {
+ givenSubscription(numberOnSim = SIM_NUMBER)
+
+ assertEquals(
+ "the SIM's own number must still be used when the carrier reports nothing",
+ SIM_NUMBER,
+ PhoneUtils(SUB_ID).getSelfRawNumber(false),
+ )
+ }
+
+ @Test
+ fun getSelfRawNumberFallsBackToTheSimWhenReadingTheCarrierNumberIsDenied() {
+ givenSubscription(numberOnSim = SIM_NUMBER)
+ givenCarrierKnowsNumber(CARRIER_NUMBER)
+ shadowOf(subscriptionManager()).setReadPhoneNumbersPermission(false)
+
+ assertEquals(
+ "a denied carrier number read must not lose the number the SIM does carry",
+ SIM_NUMBER,
+ PhoneUtils(SUB_ID).getSelfRawNumber(false),
+ )
+ }
+
+ @Test
+ fun getSelfRawNumberStillReportsThatThereIsNoSubscriptionToReadFrom() {
+ // Telephony remembers numbers for subscriptions that are no longer active, so asking it
+ // first would turn "SIM is not ready" into a send that fails further down.
+ givenCarrierKnowsNumber(CARRIER_NUMBER)
+
+ assertThrows(IllegalStateException::class.java) {
+ PhoneUtils(SUB_ID).getSelfRawNumber(false)
+ }
+ }
+
+ @Test
+ fun getValidSelfE164NumberRejectsANumberLiftedOutOfText() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "ee")
+
+ assertNull(
+ "libphonenumber pulls the first number it finds out of surrounding text, and" +
+ " whatever is stored here becomes the sender identity of every outgoing MMS",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("Call me at 5551234567"),
+ )
+ }
+
+ @Test
+ fun getValidSelfE164NumberRejectsAValidNumberLiftedOutOfText() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "ee")
+
+ assertNull(
+ "text around a number that is valid on its own is lifted just the same, so the" +
+ " strict branch cannot be trusted to have consumed the whole input either",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("Call me at +37254810027"),
+ )
+ }
+
+ @Test
+ fun getValidSelfE164NumberRejectsAVanityNumber() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "us")
+
+ assertNull(
+ "libphonenumber turns the letters of a vanity number into digits, so accepting" +
+ " one would store a number nobody typed",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("1-800-FLOWERS"),
+ )
+ }
+
+ @Test
+ fun getValidSelfE164NumberAcceptsANumberTheNumberingPlanDoesNotAssign() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "ee")
+
+ assertEquals(
+ "libphonenumber's metadata trails real numbering plans, and this is the only way" +
+ " to set a number the SIM does not carry",
+ "+3721234567",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("1234567"),
+ )
+ }
+
+ @Test
+ fun getValidSelfE164NumberAcceptsANumberTypedWithSeparators() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "ee")
+
+ assertEquals(
+ "a number is normally typed with spaces and punctuation, so rejecting those" +
+ " would refuse ordinary input",
+ "+37255550001",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("+372 (55) 55-0001"),
+ )
+ }
+
+ @Test
+ fun getValidSelfE164NumberDropsAnExtensionTypedAfterTheNumber() {
+ givenSubscription(numberOnSim = SIM_NUMBER, country = "ee")
+
+ assertEquals(
+ "an extension is separated by punctuation rather than letters, so the guard" +
+ " cannot see it and libphonenumber keeps only the number it hangs off",
+ "+37255550001",
+ PhoneUtils(SUB_ID).getValidSelfE164Number("+372 5555 0001#123"),
+ )
+ }
+
+ private fun subscriptionManager(): SubscriptionManager {
+ return context.getSystemService(SubscriptionManager::class.java)
+ }
+
+ private fun givenSubscription(numberOnSim: String, country: String? = null) {
+ shadowOf(subscriptionManager()).setActiveSubscriptionInfos(
+ ShadowSubscriptionManager.SubscriptionInfoBuilder.newBuilder()
+ .setId(SUB_ID)
+ .setNumber(numberOnSim)
+ .apply { country?.let(::setCountryIso) }
+ .buildSubscriptionInfo(),
+ )
+ }
+
+ private fun givenCarrierKnowsNumber(number: String) {
+ shadowOf(subscriptionManager()).setPhoneNumber(SUB_ID, number)
+ }
+
+ private companion object {
+ private const val SUB_ID = 2
+ private const val SIM_NUMBER = "+15550001111"
+ private const val CARRIER_NUMBER = "+37253953334"
+ private const val OVERRIDE_NUMBER = "+37255500002"
+ }
+}
diff --git a/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinCollectionExtensionsTest.kt b/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinCollectionExtensionsTest.kt
new file mode 100644
index 000000000..3c2eaeaee
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/util/core/extension/KotlinCollectionExtensionsTest.kt
@@ -0,0 +1,35 @@
+package com.android.messaging.util.core.extension
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class KotlinCollectionExtensionsTest {
+
+ @Test
+ fun allOrNull_emptyCollection_isNullRatherThanVacuouslyTrue() {
+ assertNull(emptyList().allOrNull { it > 0 })
+ }
+
+ @Test
+ fun allOrNull_everyElementMatches_isTrue() {
+ assertTrue(requireNotNull(listOf(1, 2, 3).allOrNull { it > 0 }))
+ }
+
+ @Test
+ fun allOrNull_oneElementDoesNotMatch_isFalse() {
+ assertFalse(requireNotNull(listOf(1, -2, 3).allOrNull { it > 0 }))
+ }
+
+ @Test
+ fun allOrNull_isIndependentOfElementOrder() {
+ val elements = listOf(1, -2, 3)
+
+ assertEquals(
+ elements.allOrNull { it > 0 },
+ elements.reversed().allOrNull { it > 0 },
+ )
+ }
+}
diff --git a/app/jacoco.gradle.kts b/buildSrc/src/main/kotlin/messaging.jacoco.gradle.kts
similarity index 98%
rename from app/jacoco.gradle.kts
rename to buildSrc/src/main/kotlin/messaging.jacoco.gradle.kts
index b48aec2e3..935e6d129 100644
--- a/app/jacoco.gradle.kts
+++ b/buildSrc/src/main/kotlin/messaging.jacoco.gradle.kts
@@ -1,3 +1,8 @@
+// Coverage tasks for :app. Lives here as a precompiled script plugin rather than an
+// `apply(from = "jacoco.gradle.kts")`: AGP 9's lint visits applied script files and dies
+// with "`findFirCompiledSymbol` only works on compiled declarations", aborting the whole
+// module's lint analysis.
+
import java.io.File
import java.math.BigDecimal
import org.gradle.api.file.FileCollection
@@ -5,6 +10,10 @@ import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification
import org.gradle.testing.jacoco.tasks.JacocoReport
import org.gradle.testing.jacoco.tasks.JacocoReportBase
+plugins {
+ id("jacoco")
+}
+
private val jacocoRulesDirPath = "jacoco-rules"
private val sourceDirPath = "../src"
private val uiSourceDirPath = "../src/com/android/messaging/ui"
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 65a7648d1..9449ed0d2 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -255,6 +255,13 @@
Download
Reply
+
+ Try again
+
+
+ - %d more conversation with new messages
+ - %d more conversations with new messages
+
- %d participant
@@ -378,6 +385,8 @@
Your phone number
Unknown
+
+ Not a valid phone number. Try the international format, starting with +.
Outgoing message sounds
@@ -444,6 +453,8 @@
Can\'t load attachment. Try again.
Network is not ready. Try again.
+
+ Group messages need your phone number. Add it in Settings.
Add people
New group
@@ -613,6 +624,9 @@
Enter a contact name or phone number to start a new message
+
+ No contacts found
+
Block
diff --git a/res/values/versions.xml b/res/values/versions.xml
index a02137223..8334be238 100644
--- a/res/values/versions.xml
+++ b/res/values/versions.xml
@@ -16,7 +16,7 @@
-->
- 3
+ 4