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