diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 6fda90c3d..81d97de25 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -40,9 +40,6 @@ - - - diff --git a/app/src/androidTest/java/com/android/messaging/datamodel/MessageNotificationAttachmentUriTest.kt b/app/src/androidTest/java/com/android/messaging/datamodel/MessageNotificationAttachmentUriTest.kt index 14d2445af..cf6b8c873 100644 --- a/app/src/androidTest/java/com/android/messaging/datamodel/MessageNotificationAttachmentUriTest.kt +++ b/app/src/androidTest/java/com/android/messaging/datamodel/MessageNotificationAttachmentUriTest.kt @@ -179,12 +179,34 @@ class MessageNotificationAttachmentUriTest { ) } + /** + * Re-minting the file per pass is what put the `SMS_DELIVER` broadcast over its deadline: every + * pass decoded and re-compressed the same attachment again. The file is named after the part, + * so a pass that has already transcoded it reuses it. + */ @Test - fun createStyledMessage_calledTwice_doesNotReuseTheSameFile() { + fun createStyledMessage_calledTwiceForTheSamePart_reusesTheTranscodedFile() { 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) + assertNotNull("no image was attached to the notification", first) + assertEquals("the attachment was transcoded a second time", first, second) + assertEquals( + "reusing the transcode still left a second file behind", + 1, + NotificationImageProvider.listImageFiles().size, + ) + } + + /** Parts must not share a file, or a conversation gets handed another one's photo. */ + @Test + fun createStyledMessage_forDifferentParts_usesDifferentFiles() { + val first = messageLineInfo(attachmentUri, PART_ID).createStyledMessage(SENDER).dataUri + val second = + messageLineInfo(attachmentUri, OTHER_PART_ID).createStyledMessage(SENDER).dataUri + + assertNotNull("no image was attached to the notification", first) + assertNotEquals("two parts were handed the same file", first, second) } /** @@ -193,7 +215,9 @@ class MessageNotificationAttachmentUriTest { */ @Test fun sweepNotificationImages_keepsPostedImagesAndDeletesOrphans() { - val orphan = imageFileOf(NotificationImageProvider.buildNotificationImageUri()) + val orphan = imageFileOf( + messageLineInfo(attachmentUri, OTHER_PART_ID).createStyledMessage(SENDER).dataUri, + ) val message = messageLineInfo(attachmentUri).createStyledMessage(SENDER) val posted = imageFileOf(message.dataUri) assertTrue("the platform refused to grant access to the attached image", post(message)) @@ -268,16 +292,20 @@ class MessageNotificationAttachmentUriTest { ) } - private fun messageLineInfo(uri: Uri): MessageNotificationState.MessageLineInfo { + private fun messageLineInfo( + uri: Uri, + partId: String = PART_ID, + ): MessageNotificationState.MessageLineInfo { return MessageNotificationState.MessageLineInfo( // authorId, authorFullName, authorFirstName "author", "Sender", "Sender", - // text, attachmentUri, attachmentType + // text, attachmentUri, attachmentType, attachmentPartId "Check out this photo!", uri, ContentType.IMAGE_JPEG, + partId, // isManualDownloadNeeded, avatarUri, messageId, timestamp, contactUriString false, null, @@ -297,6 +325,8 @@ class MessageNotificationAttachmentUriTest { const val POST_POLL_MILLIS = 50L const val NOTIFICATION_ID = 0x7103 const val NOTIFICATION_TAG = "BUG-015" + const val PART_ID = "7" + const val OTHER_PART_ID = "8" 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/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionA11yTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionA11yTest.kt new file mode 100644 index 000000000..a0c099a5b --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionA11yTest.kt @@ -0,0 +1,109 @@ +package com.android.messaging.ui.conversation.messagedetails + +import android.view.accessibility.AccessibilityNodeInfo +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import com.android.messaging.R +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +internal class MessageDetailsCopyActionA11yTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun copyActionSitsOnAScreenReaderFocusableNode() { + composeTestRule.setContent { + AppTheme { + MessageDetailsStatusSection( + sentTimestamp = null, + receivedTimestamp = RECEIVED_TIMESTAMP, + onCopy = {}, + ) + } + } + composeTestRule.waitForIdle() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + val copyLabel = context.getString(R.string.copy_to_clipboard) + val receivedLabel = context.getString(R.string.message_details_received_label) + + val nodesOfferingCopy = awaitNodesOfferingCopy(copyLabel = copyLabel) + + assertTrue( + "No accessibility node exposes the \"$copyLabel\" custom action at all", + nodesOfferingCopy.isNotEmpty(), + ) + + val focusableNodes = nodesOfferingCopy.filter { it.isScreenReaderFocusable } + + assertTrue( + "The \"$copyLabel\" action exists on ${nodesOfferingCopy.size} node(s) but none is " + + "screen reader focusable, so TalkBack never offers it: " + + nodesOfferingCopy.joinToString { it.describe() }, + focusableNodes.isNotEmpty(), + ) + + val receivedValue = formatMessageDetailsTimestamp(timestampMillis = RECEIVED_TIMESTAMP) + + assertTrue( + "The focusable node offering \"$copyLabel\" announces nothing about the row it " + + "copies: " + focusableNodes.joinToString { it.dumpSubtree() }, + focusableNodes.any { node -> + node.subtreeText().let { + it.contains(receivedLabel) && it.contains(receivedValue) + } + }, + ) + } + + private fun AccessibilityNodeInfo.subtreeText(): String { + return collectNodes().joinToString(separator = " ") { node -> + listOfNotNull(node.text, node.contentDescription).joinToString(separator = " ") + } + } + + private fun AccessibilityNodeInfo.dumpSubtree(): String { + return collectNodes().joinToString(separator = " / ") { it.describe() } + } + + private fun awaitNodesOfferingCopy(copyLabel: String): List { + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + repeat(A11Y_TREE_POLL_ATTEMPTS) { + val root = uiAutomation.rootInActiveWindow + val matches = root?.collectNodes().orEmpty().filter { node -> + node.actionList.any { it.label == copyLabel } + } + if (matches.isNotEmpty()) { + return matches + } + Thread.sleep(A11Y_TREE_POLL_INTERVAL_MILLIS) + } + return emptyList() + } + + private fun AccessibilityNodeInfo.collectNodes(): List { + return buildList { + add(this@collectNodes) + repeat(childCount) { index -> + getChild(index)?.let { addAll(it.collectNodes()) } + } + } + } + + private fun AccessibilityNodeInfo.describe(): String { + return "[text=$text, contentDescription=$contentDescription, " + + "screenReaderFocusable=$isScreenReaderFocusable, " + + "actions=${actionList.mapNotNull { it.label }}]" + } + + private companion object { + private const val RECEIVED_TIMESTAMP = 1_700_000_000_000L + private const val A11Y_TREE_POLL_ATTEMPTS = 20 + private const val A11Y_TREE_POLL_INTERVAL_MILLIS = 250L + } +} diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderA11yTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderA11yTest.kt new file mode 100644 index 000000000..2c67f7e93 --- /dev/null +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderA11yTest.kt @@ -0,0 +1,123 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import android.view.accessibility.AccessibilityNodeInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +internal class ConversationMessageSenderA11yTest : BaseConversationMessageRenderingTest() { + + @Test + fun incomingMessageIsAnnouncedWithItsSender() { + setConversationMessageContent( + message = message( + text = INCOMING_TEXT, + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = SENDER_DISPLAY_NAME, + ), + showIncomingParticipantIdentity = false, + ) + + assertAnnouncedWithTheBody( + body = INCOMING_TEXT, + announcement = string( + resourceId = R.string.incoming_sender_content_description, + SENDER_DISPLAY_NAME, + ), + ) + } + + @Test + fun outgoingMessageIsAnnouncedAsSentByTheUser() { + setConversationMessageContent(message = message(text = OUTGOING_TEXT)) + + assertAnnouncedWithTheBody( + body = OUTGOING_TEXT, + announcement = string(resourceId = R.string.outgoing_sender_content_description), + ) + } + + private fun assertAnnouncedWithTheBody(body: String, announcement: String) { + composeTestRule.waitForIdle() + + val bubbleNodes = awaitFocusableNodesSpeaking(text = body) + + assertTrue( + "No screen reader focusable node speaks the message body \"$body\" at all. Tree: " + + dumpTree(), + bubbleNodes.isNotEmpty(), + ) + + assertTrue( + "TalkBack cannot tell who sent this message: the focusable node speaking \"$body\" " + + "never says \"$announcement\". Nodes: " + + bubbleNodes.joinToString { it.dumpSubtree() }, + bubbleNodes.any { node -> node.subtreeText().contains(announcement) }, + ) + } + + /** The bubbles TalkBack would stop on and read the body out from. */ + private fun awaitFocusableNodesSpeaking(text: String): List { + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + repeat(A11Y_TREE_POLL_ATTEMPTS) { + val matches = uiAutomation.rootInActiveWindow?.collectNodes().orEmpty().filter { node -> + node.isScreenReaderFocusable && node.subtreeText().contains(text) + } + if (matches.isNotEmpty()) { + return matches + } + Thread.sleep(A11Y_TREE_POLL_INTERVAL_MILLIS) + } + return emptyList() + } + + private fun dumpTree(): String { + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + return uiAutomation.rootInActiveWindow?.dumpSubtree() ?: "" + } + + private fun AccessibilityNodeInfo.subtreeText(): String { + return collectNodes().joinToString(separator = " ") { node -> + listOfNotNull(node.text, node.contentDescription).joinToString(separator = " ") + } + } + + private fun AccessibilityNodeInfo.dumpSubtree(): String { + return collectNodes().joinToString(separator = " / ") { it.describe() } + } + + private fun AccessibilityNodeInfo.collectNodes(): List { + return buildList { + add(this@collectNodes) + repeat(childCount) { index -> + getChild(index)?.let { addAll(it.collectNodes()) } + } + } + } + + private fun AccessibilityNodeInfo.describe(): String { + return "[text=$text, contentDescription=$contentDescription, " + + "screenReaderFocusable=$isScreenReaderFocusable]" + } + + private fun string(resourceId: Int, vararg formatArgs: Any): String { + return InstrumentationRegistry + .getInstrumentation() + .targetContext + .getString(resourceId, *formatArgs) + } + + private companion object { + private const val INCOMING_TEXT = "Can you review this before tonight?" + private const val OUTGOING_TEXT = "I am on my way." + private const val SENDER_DISPLAY_NAME = "Ada Lovelace" + private const val A11Y_TREE_POLL_ATTEMPTS = 20 + private const val A11Y_TREE_POLL_INTERVAL_MILLIS = 250L + } +} diff --git a/app/src/test/java/com/android/messaging/datamodel/ActionSyncTestDataModel.java b/app/src/test/java/com/android/messaging/datamodel/ActionSyncTestDataModel.java index 65d6a4356..b8b11c0b8 100644 --- a/app/src/test/java/com/android/messaging/datamodel/ActionSyncTestDataModel.java +++ b/app/src/test/java/com/android/messaging/datamodel/ActionSyncTestDataModel.java @@ -8,9 +8,7 @@ import com.android.messaging.datamodel.data.ConversationData; import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.GalleryGridItemData; import com.android.messaging.datamodel.data.LaunchConversationData; -import com.android.messaging.datamodel.data.MediaPickerData; import com.android.messaging.util.Assert; public class ActionSyncTestDataModel extends DataModel { @@ -37,16 +35,6 @@ public ConversationData createConversationData(final Context context, throw new UnsupportedOperationException(); } - @Override - public MediaPickerData createMediaPickerData(final Context context) { - throw new UnsupportedOperationException(); - } - - @Override - public GalleryGridItemData createGalleryGridItemData() { - throw new UnsupportedOperationException(); - } - @Override public LaunchConversationData createLaunchConversationData( final LaunchConversationData.LaunchConversationDataListener listener) { diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt index af8d2df5a..1e967c5e0 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapperImplTest.kt @@ -3,6 +3,7 @@ package com.android.messaging.data.conversation.mapper import android.net.Uri import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.MediaScratchFileProvider import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID @@ -30,7 +31,7 @@ class ConversationMessageDataDraftMapperImplTest { MessagePartData.createMediaMessagePart( "Caption", "image/jpeg", - Uri.parse("content://media/image/1"), + scratchUri("1.jpg"), 640, 480, ), @@ -48,7 +49,7 @@ class ConversationMessageDataDraftMapperImplTest { listOf( createAttachment( contentType = "image/jpeg", - contentUri = "content://media/image/1", + contentUri = scratchUri("1.jpg").toString(), captionText = "Caption", width = 640, height = 480, @@ -100,7 +101,7 @@ class ConversationMessageDataDraftMapperImplTest { messageData.addPart( MessagePartData.createMediaMessagePart( "image/png", - Uri.parse("content://media/image/2"), + scratchUri("2.png"), MessagePartData.UNSPECIFIED_SIZE, MessagePartData.UNSPECIFIED_SIZE, ), @@ -110,7 +111,7 @@ class ConversationMessageDataDraftMapperImplTest { val attachment = draft.attachments.single() assertEquals("image/png", attachment.contentType) - assertEquals("content://media/image/2", attachment.contentUri) + assertEquals(scratchUri("2.png").toString(), attachment.contentUri) assertEquals("", attachment.captionText) assertNull(attachment.width) assertNull(attachment.height) @@ -126,7 +127,7 @@ class ConversationMessageDataDraftMapperImplTest { messageData.addPart( MessagePartData.createMediaMessagePart( "", - Uri.parse("content://media/image/3"), + scratchUri("3.jpg"), 320, 240, ), @@ -142,7 +143,7 @@ class ConversationMessageDataDraftMapperImplTest { messageData.addPart( MessagePartData.createMediaMessagePart( "audio/mp3", - Uri.parse("content://media/audio/4"), + scratchUri("4.mp3"), 0, 0, ), @@ -154,7 +155,7 @@ class ConversationMessageDataDraftMapperImplTest { listOf( createAttachment( contentType = "audio/mp3", - contentUri = "content://media/audio/4", + contentUri = scratchUri("4.mp3").toString(), width = 0, height = 0, ), @@ -164,27 +165,54 @@ class ConversationMessageDataDraftMapperImplTest { } @Test - fun map_dropsAttachmentsBackedByPhotoPickerUris() { + fun map_dropsAttachmentsBackedByMediaStoreUris() { val messageData = MessageData.createDraftSmsMessage( CONVERSATION_ID.value, "self-1", "Hello", ) - messageData.addPart( - MessagePartData.createMediaMessagePart( - "image/jpeg", - Uri.parse( - "content://media/picker/0/" + - "com.android.providers.media.photopicker/media/1", + listOf( + "content://media/picker/0/com.android.providers.media.photopicker/media/1", + "content://media/external/images/media/1", + "content://com.android.providers.media.documents/document/image%3A1", + // An authority that merely starts with "media" belongs to somebody else. + "content://media.example/images/1", + // Forwarding seeds a raw mms part uri, readable as the default SMS app. + "content://mms/part/1", + ).forEach { contentUri -> + messageData.addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + Uri.parse(contentUri), + 320, + 240, ), - 320, - 240, - ), - ) + ) + } val draft = mapper.map(messageData = messageData) - assertEquals(emptyList(), draft.attachments) + assertEquals( + listOf( + createAttachment( + contentType = "image/jpeg", + contentUri = "content://media.example/images/1", + width = 320, + height = 240, + ), + createAttachment( + contentType = "image/jpeg", + contentUri = "content://mms/part/1", + width = 320, + height = 240, + ), + ), + draft.attachments, + ) + } + + private fun scratchUri(name: String): Uri { + return MediaScratchFileProvider.getUriBuilder().appendPath(name).build() } private fun createAttachment( diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt index bb1663c91..026ea0e9b 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/ConversationDraftsRepositoryImplTest.kt @@ -10,6 +10,7 @@ import com.android.messaging.data.conversation.mapper.ConversationMessageDataDra import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.store.ConversationDraftStore +import com.android.messaging.datamodel.MediaScratchFileProvider import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData @@ -413,7 +414,7 @@ class ConversationDraftsRepositoryImplTest { addPart( MessagePartData.createMediaMessagePart( "audio/3gpp", - Uri.parse("content://media/audio/1"), + MediaScratchFileProvider.getUriBuilder().appendPath("1.3gpp").build(), 0, 0, ), @@ -431,7 +432,7 @@ class ConversationDraftsRepositoryImplTest { addPart( MessagePartData.createMediaMessagePart( "image/jpeg", - Uri.parse("content://media/image/1"), + MediaScratchFileProvider.getUriBuilder().appendPath("1.jpg").build(), 640, 480, ), diff --git a/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt deleted file mode 100644 index 48f985773..000000000 --- a/app/src/test/kotlin/com/android/messaging/data/media/repository/ConversationMediaRepositoryImplTest.kt +++ /dev/null @@ -1,230 +0,0 @@ -package com.android.messaging.data.media.repository - -import android.content.ContentResolver -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri -import android.os.Bundle -import android.provider.MediaStore -import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.testutil.MainDispatcherRule -import com.android.messaging.util.ContentType -import com.android.messaging.util.UriUtil -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -class ConversationMediaRepositoryImplTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() - - @Test - fun getRecentMedia_queriesMediaStoreWithExpectedProjectionAndArgs() { - runTest( - context = mainDispatcherRule.testDispatcher - ) { - val contentResolver = mockk() - val capturedUri = slot() - val capturedProjection = slot>() - val capturedQueryArgs = slot() - - every { - contentResolver.query( - capture(capturedUri), - capture(capturedProjection), - capture(capturedQueryArgs), - null, - ) - } returns createCursor() - - val repository = createRepository(contentResolver = contentResolver) - - repository.getRecentMedia(limit = 37).single() - - verify(exactly = 1) { - contentResolver.query( - any(), - any>(), - any(), - null, - ) - } - - assertRecentMediaQuery( - uri = capturedUri.captured, - projection = capturedProjection.captured, - queryArgs = capturedQueryArgs.captured, - limit = 37, - ) - } - } - - @Test - fun getRecentMedia_mapsCursorRows() { - runTest(context = mainDispatcherRule.testDispatcher) { - val contentResolver = mockk() - stubQuery( - contentResolver = contentResolver, - result = createCursor(), - ) - val repository = createRepository(contentResolver = contentResolver) - - val items = repository.getRecentMedia(limit = 37).single() - - assertEquals( - listOf( - ConversationMediaItem( - mediaId = "10", - contentUri = UriUtil.getContentUriForMediaStoreId(10L).toString(), - contentType = ContentType.IMAGE_UNSPECIFIED, - width = null, - height = 720, - durationMillis = null, - ), - ConversationMediaItem( - mediaId = "11", - contentUri = UriUtil.getContentUriForMediaStoreId(11L).toString(), - contentType = ContentType.VIDEO_UNSPECIFIED, - width = 1920, - height = 1080, - durationMillis = 1234L, - ), - ), - items, - ) - } - } - - @Test - fun getRecentMedia_returnsEmptyListForNullCursor() { - runTest( - context = mainDispatcherRule.testDispatcher - ) { - val contentResolver = mockk() - stubQuery( - contentResolver = contentResolver, - result = null, - ) - val repository = createRepository(contentResolver = contentResolver) - - val items = repository.getRecentMedia(limit = 7).single() - - assertEquals(emptyList(), items) - } - } - - private fun createRepository( - contentResolver: ContentResolver, - ): ConversationMediaRepositoryImpl { - return ConversationMediaRepositoryImpl( - contentResolver = contentResolver, - ioDispatcher = mainDispatcherRule.testDispatcher, - ) - } - - private fun stubQuery( - contentResolver: ContentResolver, - result: Cursor?, - ) { - every { - contentResolver.query( - any(), - any>(), - any(), - null, - ) - } returns result - } - - @Suppress("SameParameterValue") - private fun assertRecentMediaQuery( - uri: Uri, - projection: Array, - queryArgs: Bundle, - limit: Int, - ) { - assertEquals( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - uri, - ) - assertEquals( - listOf( - MediaStore.Files.FileColumns._ID, - MediaStore.Files.FileColumns.MEDIA_TYPE, - MediaStore.Files.FileColumns.MIME_TYPE, - MediaStore.Files.FileColumns.DATE_ADDED, - MediaStore.Files.FileColumns.WIDTH, - MediaStore.Files.FileColumns.HEIGHT, - MediaStore.Video.VideoColumns.DURATION, - ), - projection.toList(), - ) - assertEquals( - "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN " + - "(${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}," + - "${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO})", - queryArgs.getString(ContentResolver.QUERY_ARG_SQL_SELECTION), - ) - assertEquals( - listOf(MediaStore.Files.FileColumns.DATE_ADDED), - queryArgs.getStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS)?.toList(), - ) - assertEquals( - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, - queryArgs.getInt(ContentResolver.QUERY_ARG_SORT_DIRECTION), - ) - assertEquals( - limit, - queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT), - ) - } - - private fun createCursor(): Cursor { - return MatrixCursor( - arrayOf( - MediaStore.Files.FileColumns._ID, - MediaStore.Files.FileColumns.MEDIA_TYPE, - MediaStore.Files.FileColumns.MIME_TYPE, - MediaStore.Files.FileColumns.DATE_ADDED, - MediaStore.Files.FileColumns.WIDTH, - MediaStore.Files.FileColumns.HEIGHT, - MediaStore.Video.VideoColumns.DURATION, - ), - ).apply { - addRow( - arrayOf( - 10L, - MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE, - "", - 1L, - 0, - 720, - 0L, - ), - ) - addRow( - arrayOf( - 11L, - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO, - "", - 2L, - 1920, - 1080, - 1234L, - ), - ) - } - } -} diff --git a/app/src/test/kotlin/com/android/messaging/data/subscription/SubscriptionSelectionResolverTest.kt b/app/src/test/kotlin/com/android/messaging/data/subscription/SubscriptionSelectionResolverTest.kt new file mode 100644 index 000000000..f332035f2 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/subscription/SubscriptionSelectionResolverTest.kt @@ -0,0 +1,123 @@ +package com.android.messaging.data.subscription + +import com.android.messaging.data.conversation.model.ParticipantId +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.subscription.model.SubId +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.datamodel.data.ParticipantData +import kotlinx.collections.immutable.persistentListOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +internal class SubscriptionSelectionResolverTest { + + private val firstSubscription = createSubscription( + selfParticipantId = FIRST_SELF_PARTICIPANT_ID, + subId = FIRST_SUB_ID, + slotId = 1, + ) + private val secondSubscription = createSubscription( + selfParticipantId = SECOND_SELF_PARTICIPANT_ID, + subId = SECOND_SUB_ID, + slotId = 2, + ) + private val subscriptions = persistentListOf(firstSubscription, secondSubscription) + + @Test + fun resolve_selfParticipantIdMatches_selectsThatSubscription() { + val selectedSubscription = resolveSelectedSubscription( + subscriptions = subscriptions, + selectedSelfParticipantId = ParticipantId(SECOND_SELF_PARTICIPANT_ID), + defaultSmsSubscriptionId = SubId(FIRST_SUB_ID), + ) + + assertEquals(secondSubscription, selectedSubscription) + } + + @Test + fun resolve_selfParticipantIdMatchesNothing_selectsDefaultSmsSubscription() { + val selectedSubscription = resolveSelectedSubscription( + subscriptions = subscriptions, + selectedSelfParticipantId = ParticipantId(UNKNOWN_SELF_PARTICIPANT_ID), + defaultSmsSubscriptionId = SubId(SECOND_SUB_ID), + ) + + assertEquals(secondSubscription, selectedSubscription) + } + + @Test + fun resolve_selfParticipantIdIsNull_selectsDefaultSmsSubscription() { + val selectedSubscription = resolveSelectedSubscription( + subscriptions = subscriptions, + selectedSelfParticipantId = null, + defaultSmsSubscriptionId = SubId(SECOND_SUB_ID), + ) + + assertEquals(secondSubscription, selectedSubscription) + } + + @Test + fun resolve_defaultSmsSubscriptionIsNotActive_selectsFirstSubscription() { + val selectedSubscription = resolveSelectedSubscription( + subscriptions = subscriptions, + selectedSelfParticipantId = null, + defaultSmsSubscriptionId = SubId(REMOVED_SUB_ID), + ) + + assertEquals(firstSubscription, selectedSubscription) + } + + @Test + fun resolve_defaultSmsSubscriptionIdIsUnset_ignoresEmulatedSubscription() { + val emulatedSubscription = createSubscription( + selfParticipantId = EMULATED_SELF_PARTICIPANT_ID, + subId = ParticipantData.DEFAULT_SELF_SUB_ID, + slotId = 2, + ) + + val selectedSubscription = resolveSelectedSubscription( + subscriptions = persistentListOf(firstSubscription, emulatedSubscription), + selectedSelfParticipantId = null, + defaultSmsSubscriptionId = SubId(ParticipantData.DEFAULT_SELF_SUB_ID), + ) + + assertEquals(firstSubscription, selectedSubscription) + } + + @Test + fun resolve_noSubscriptions_returnsNull() { + val selectedSubscription = resolveSelectedSubscription( + subscriptions = persistentListOf(), + selectedSelfParticipantId = ParticipantId(FIRST_SELF_PARTICIPANT_ID), + defaultSmsSubscriptionId = SubId(FIRST_SUB_ID), + ) + + assertNull(selectedSubscription) + } + + private fun createSubscription( + selfParticipantId: String, + subId: Int, + slotId: Int, + ): Subscription { + return Subscription( + selfParticipantId = ParticipantId(selfParticipantId), + subId = SubId(subId), + label = ConversationSubscriptionLabel.Slot(slotId = slotId), + displayDestination = null, + displaySlotId = slotId, + color = 0, + ) + } + + private companion object { + private const val FIRST_SELF_PARTICIPANT_ID = "self-participant-1" + private const val SECOND_SELF_PARTICIPANT_ID = "self-participant-2" + private const val UNKNOWN_SELF_PARTICIPANT_ID = "self-participant-default" + private const val EMULATED_SELF_PARTICIPANT_ID = "debug_sim_emulated_2" + private const val FIRST_SUB_ID = 11 + private const val SECOND_SUB_ID = 12 + private const val REMOVED_SUB_ID = 13 + } +} diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsConcurrentPassTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsConcurrentPassTest.kt new file mode 100644 index 000000000..6ce32d77d --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsConcurrentPassTest.kt @@ -0,0 +1,78 @@ +package com.android.messaging.datamodel + +import com.android.messaging.FactoryTestAccess +import com.android.messaging.testutil.installTestFactory +import com.android.messaging.util.PhoneUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.TimeUnit.SECONDS +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class BugleNotificationsConcurrentPassTest { + + private val phoneUtils = mockk(relaxed = true) + + @Before + fun setUp() { + installTestFactory( + context = RuntimeEnvironment.getApplication().applicationContext, + phoneUtils = phoneUtils, + ) + } + + @After + fun tearDown() { + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun update_whileAnotherPassIsRunning_waitsForIt() { + val firstIsInside = CountDownLatch(1) + val secondGotInside = CountDownLatch(1) + val overlapped = AtomicBoolean(false) + val passes = AtomicInteger() + + // The first thing a pass does once it is under way. Returning false makes it stop here, so + // the test observes the pass boundary and nothing else. + every { phoneUtils.isDefaultSmsApp() } answers { + when (passes.getAndIncrement()) { + 0 -> { + firstIsInside.countDown() + overlapped.set(secondGotInside.await(OVERLAP_TIMEOUT_MILLIS, MILLISECONDS)) + } + + else -> secondGotInside.countDown() + } + false + } + + val first = thread { BugleNotifications.update("1", BugleNotifications.UPDATE_MESSAGES) } + assertTrue("the first pass never started", firstIsInside.await(5, SECONDS)) + val second = thread { BugleNotifications.update("2", BugleNotifications.UPDATE_MESSAGES) } + first.join() + second.join() + + assertEquals("both passes should have run", 2, passes.get()) + assertFalse("a second notification pass ran inside the first one", overlapped.get()) + } + + private companion object { + const val OVERLAP_TIMEOUT_MILLIS = 500L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsImageCacheTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsImageCacheTest.kt new file mode 100644 index 000000000..22581d0e3 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsImageCacheTest.kt @@ -0,0 +1,117 @@ +package com.android.messaging.datamodel + +import android.graphics.Bitmap +import android.net.Uri +import com.android.messaging.FactoryTestAccess +import com.android.messaging.datamodel.media.ImageResource +import com.android.messaging.datamodel.media.MediaResourceManager +import com.android.messaging.testutil.FakeBuglePrefs +import com.android.messaging.testutil.createTestFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlin.time.Duration.Companion.minutes +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +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 BugleNotificationsImageCacheTest { + + private val context = RuntimeEnvironment.getApplication().applicationContext + private val mediaResourceManager = mockk() + + @Before + fun setUp() { + val factory = createTestFactory( + context = context, + dataModel = mockk(relaxed = true), + prefs = FakeBuglePrefs(), + ) + every { factory.mediaResourceManager } returns mediaResourceManager + FactoryTestAccess.install(factory) + + every { mediaResourceManager.requestMediaResourceSync(any()) } answers { + mockk(relaxed = true) { + every { bitmap } returns Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888) + } + } + } + + @After + fun tearDown() { + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun getNotificationImageUri_forSameAttachmentPart_transcodesOnce() { + val first = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + val second = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + + assertNotNull(first) + assertEquals(first, second) + verify(exactly = 1) { mediaResourceManager.requestMediaResourceSync(any()) } + } + + @Test + fun getNotificationImageUri_forDifferentAttachmentParts_transcodesEach() { + val first = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + val second = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "8") + + assertNotNull(first) + assertNotNull(second) + assertNotEquals(first, second) + verify(exactly = 2) { mediaResourceManager.requestMediaResourceSync(any()) } + } + + /** The sweep reclaims images once their notification is gone; the next pass must rebuild them. */ + @Test + fun getNotificationImageUri_afterCachedImageSwept_transcodesAgain() { + val first = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + NotificationImageProvider.getFileFromUri(first)!!.delete() + + val second = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + + assertNotNull(second) + assertEquals(first, second) + verify(exactly = 2) { mediaResourceManager.requestMediaResourceSync(any()) } + } + + /** + * A cache hit has to touch the file, or [BugleNotifications.sweepNotificationImages] reclaims an + * image that a notification posted in this pass still points at. + */ + @Test + fun getNotificationImageUri_onCacheHit_touchesFileSoTheSweepSpareIt() { + val uri = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + val file = NotificationImageProvider.getFileFromUri(uri)!! + val stale = System.currentTimeMillis() - 10.minutes.inWholeMilliseconds + assertTrue(file.setLastModified(stale)) + + BugleNotifications.getNotificationImageUri(context, ATTACHMENT, "7") + + assertTrue(file.lastModified() > stale) + } + + /** Parts that are not persisted yet have no id, and must still get an image. */ + @Test + fun getNotificationImageUri_withoutPartId_stillTranscodes() { + val uri = BugleNotifications.getNotificationImageUri(context, ATTACHMENT, null) + + assertNotNull(uri) + assertTrue(NotificationImageProvider.isNotificationImageUri(uri)) + } + + private companion object { + val ATTACHMENT: Uri = Uri.parse("content://mms/part/1") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMessageLimitTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMessageLimitTest.kt index 8ab4059d9..771af23ae 100644 --- a/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMessageLimitTest.kt +++ b/app/src/test/kotlin/com/android/messaging/datamodel/BugleNotificationsMessageLimitTest.kt @@ -85,6 +85,7 @@ class BugleNotificationsMessageLimitTest { "Message $messageId", null, null, + null, false, null, messageId, 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 266335523..05541ddbb 100644 --- a/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt +++ b/app/src/test/kotlin/com/android/messaging/datamodel/DatabaseUpgradeHelperTest.kt @@ -11,6 +11,7 @@ import com.android.messaging.testutil.installTestFactory import io.mockk.unmockkAll import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -68,6 +69,51 @@ class DatabaseUpgradeHelperTest { } } + /** + * rebuildTables() drops the parts table, and that takes its sqlite_sequence row along, so + * parts._id restarts at 1. Notification images are named after the part they were transcoded + * from and outlive the database, so a leftover image for part 1 would be served as the image + * of whatever part next takes that id - showing an unrelated photo in a notification. + */ + @Test + fun rebuildTables_discardsCachedNotificationImages() { + val stale = checkNotNull( + NotificationImageProvider.buildNotificationImageUri("1") + ?.let(NotificationImageProvider::getFileFromUri) + ) + stale.writeBytes(byteArrayOf(1, 2, 3)) + + SQLiteDatabase.create(null).use(DatabaseHelper::rebuildTables) + + assertFalse( + "a cached notification image outlived the part id space it was named after", + stale.exists(), + ) + } + + /** + * A corrupt database is deleted and recreated underneath us, and SQLiteOpenHelper answers the + * resulting version zero with onCreate() rather than rebuildTables(). The cleanup has to sit + * where both paths meet, or images cached against the old part ids survive to be served as + * some unrelated part's picture. + */ + @Test + fun onCreate_discardsCachedNotificationImages() { + val context = RuntimeEnvironment.getApplication().applicationContext + val stale = checkNotNull( + NotificationImageProvider.buildNotificationImageUri("1") + ?.let(NotificationImageProvider::getFileFromUri) + ) + stale.writeBytes(byteArrayOf(1, 2, 3)) + + SQLiteDatabase.create(null).use { DatabaseHelper.getInstance(context).onCreate(it) } + + assertFalse( + "a cached notification image outlived the database it was named against", + stale.exists(), + ) + } + @Test fun upgradeToVersion3_createsPinnedColumnAndIndex() { val table = DatabaseHelper.CONVERSATIONS_TABLE diff --git a/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsSendPathTest.kt b/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsSendPathTest.kt new file mode 100644 index 000000000..e3d232a29 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsSendPathTest.kt @@ -0,0 +1,88 @@ +package com.android.messaging.sms + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import com.android.messaging.FactoryTestAccess +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.testutil.TEST_CONVERSATION_ID_VALUE as CONVERSATION_ID +import com.android.messaging.testutil.installTestFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The app holds no media read permissions, so an attachment uri carried by an old message can be + * unreadable by the time that message is sent or resent. The send path has to fail the message + * instead of letting the SecurityException escape the action service and kill the process. + */ +@RunWith(RobolectricTestRunner::class) +class MmsUtilsSendPathTest { + + private val contentResolver = mockk() + private val context = mockk() + + @Before + fun setUp() { + every { context.contentResolver } returns contentResolver + installTestFactory(context = context) + } + + @After + fun tearDown() { + FactoryTestAccess.reset() + } + + @Test + fun insertSendingMmsMessage_returnsNullWhenAnAttachmentIsNoLongerReadable() { + every { contentResolver.openInputStream(any()) } throws SecurityException( + "Permission Denial: opening provider com.android.providers.media.MediaProvider", + ) + + val messageUri = MmsUtils.insertSendingMmsMessage( + context, + listOf(RECIPIENT), + createMmsDraftWithImageAttachment(), + SUB_ID, + SELF_PHONE_NUMBER, + TIMESTAMP, + ) + + assertNull(messageUri) + verify { contentResolver.openInputStream(ATTACHMENT_URI) } + } + + private fun createMmsDraftWithImageAttachment(): MessageData { + val messageData = MessageData.createDraftMmsMessage( + CONVERSATION_ID, + "self-1", + "Hello", + "Subject", + ) + messageData.addPart( + MessagePartData.createMediaMessagePart( + "image/jpeg", + ATTACHMENT_URI, + 320, + 240, + ), + ) + + return messageData + } + + private companion object { + private const val RECIPIENT = "+15555550100" + private const val SELF_PHONE_NUMBER = "+15555550101" + private const val SUB_ID = 1 + private const val TIMESTAMP = 1_700_000_000_000L + private val ATTACHMENT_URI: Uri = Uri.parse("content://media/external/images/media/1") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/PredictiveBackTransitionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/PredictiveBackTransitionTest.kt new file mode 100644 index 000000000..91b5fe948 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/PredictiveBackTransitionTest.kt @@ -0,0 +1,63 @@ +package com.android.messaging.ui.common.components + +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.navigationevent.NavigationEvent +import kotlin.math.roundToLong +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PredictiveBackTransitionTest { + + @Test + fun predictiveBackTranslation_pushesAwayFromTheSwipedEdge() { + val fromLeft = translationFrom(NavigationEvent.EDGE_LEFT) + val fromRight = translationFrom(NavigationEvent.EDGE_RIGHT) + + assertTrue("left swipe should push right, was $fromLeft", fromLeft > 0) + assertEquals(-fromLeft, fromRight) + } + + @Test + fun predictiveBackTranslation_keepsTheScaledScreenInsideTheFarEdge() { + val scaledHalfWidth = FULL_WIDTH * PREDICTIVE_BACK_TARGET_SCALE / 2f + val farEdge = FULL_WIDTH / 2f + scaledHalfWidth + translationFrom(NavigationEvent.EDGE_LEFT) + + val expectedMargin = FULL_WIDTH * PREDICTIVE_BACK_EDGE_MARGIN_FRACTION + assertEquals(FULL_WIDTH - expectedMargin, farEdge, 1f) + } + + @Test + fun predictiveBackTranslation_withoutAnEdgeOnlyShrinks() { + assertEquals(0, translationFrom(NavigationEvent.EDGE_NONE)) + } + + /** + * The gesture seeks the transition by play time, so a screen only follows the finger while + * half the play time means half the movement. An eased spec would run ahead of the finger. + */ + @Test + fun predictiveBackSpec_movesEvenlyAcrossItsPlayTime() { + val spec = predictiveBackSpec().vectorize(Float.VectorConverter) + val start = AnimationVector1D(0f) + val end = AnimationVector1D(1f) + val durationNanos = spec.getDurationNanos(start, end, start) + + for (fraction in listOf(0.25f, 0.5f, 0.75f)) { + val playTimeNanos = (fraction * durationNanos).roundToLong() + val moved = spec.getValueFromNanos(playTimeNanos, start, end, start).value + + assertEquals("at $fraction of the play time", fraction, moved, TOLERANCE) + } + } + + private fun translationFrom(swipeEdge: Int): Int { + return predictiveBackTranslation(swipeEdge = swipeEdge, fullWidth = FULL_WIDTH) + } + + private companion object { + const val FULL_WIDTH = 1080 + const val TOLERANCE = 0.01f + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt new file mode 100644 index 000000000..d41cbef1e --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt @@ -0,0 +1,100 @@ +package com.android.messaging.ui.common.components.composer + +import android.text.InputType +import android.view.inputmethod.EditorInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS +import com.android.messaging.ui.core.AppTheme +import kotlinx.coroutines.awaitCancellation +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) +internal class MessageComposeBarInputTypeTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun messageComposeField_reportsSentenceCapitalizationToTheKeyboard() { + val inputType = focusedFieldInputType() + + assertTrue(inputType and InputType.TYPE_TEXT_FLAG_CAP_SENTENCES != 0) + } + + @Test + fun messageComposeField_reportsShortMessageVariationToTheKeyboard() { + val inputType = focusedFieldInputType() + + assertEquals( + InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE, + inputType and InputType.TYPE_MASK_VARIATION, + ) + } + + @Test + fun messageComposeField_staysMultiLine() { + val inputType = focusedFieldInputType() + + assertTrue(inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE != 0) + } + + private fun focusedFieldInputType(): Int { + var editorInfo: EditorInfo? = null + + composeTestRule.setContent { + RecordingEditorInfo(onEditorInfo = { editorInfo = it }) { + AppTheme { + MessageComposeBar( + text = "", + onTextChange = {}, + isFieldEnabled = true, + isFieldContentHidden = false, + fieldFocusRequester = null, + fieldStateDescription = null, + fieldTestTag = MESSAGE_COMPOSE_FIELD_TEST_TAG, + sendAction = {}, + ) + } + } + } + + composeTestRule + .onNodeWithTag(testTag = MESSAGE_COMPOSE_FIELD_TEST_TAG) + .performClick() + + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + editorInfo != null + } + + return requireNotNull(editorInfo).inputType + } +} + +/** + * Captures the [EditorInfo] the focused text field below would hand to the keyboard, instead of + * letting the request reach the real input method. + */ +@Composable +private fun RecordingEditorInfo( + onEditorInfo: (EditorInfo) -> Unit, + content: @Composable () -> Unit, +) { + InterceptPlatformTextInput( + interceptor = { request, _ -> + val editorInfo = EditorInfo() + request.createInputConnection(outAttributes = editorInfo) + onEditorInfo(editorInfo) + awaitCancellation() + }, + content = content, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt index a4fc9ffb9..8e5685eb3 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/entry/NewChatScreenTest.kt @@ -24,10 +24,12 @@ import com.android.messaging.ui.recipientselection.component.row.CONTACT_ID import com.android.messaging.ui.recipientselection.component.row.MOBILE_NORMALIZED_DESTINATION import com.android.messaging.ui.recipientselection.component.row.contactItem import com.android.messaging.ui.recipientselection.component.row.selectedRecipient +import com.android.messaging.ui.recipientselection.model.picker.RecipientPickerListItem import com.android.messaging.ui.recipientselection.model.picker.RecipientPickerUiState import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -74,20 +76,7 @@ class NewChatScreenTest { val screenModel = createScreenModel( initialUiState = NewChatUiState( recipientPickerUiState = RecipientPickerUiState( - items = persistentListOf( - *Array(size = 30) { index -> - contactItem( - id = index.toLong(), - displayName = "Contact $index", - destination = "+1 555 ${ - index.toString().padStart(length = 4, padChar = '0') - }", - normalizedDestination = "+1555${ - index.toString().padStart(length = 4, padChar = '0') - }", - ) - }, - ), + items = contactItems(count = 30), canLoadMore = true, ), ), @@ -220,6 +209,50 @@ class NewChatScreenTest { .assertCountEquals(expectedSize = 1) } + @Test + fun createGroupButton_staysVisibleWhileTheContactListScrolls() { + val screenModel = createScreenModel( + initialUiState = NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = contactItems(count = 30), + ), + ), + ) + + setContent(screenModel = screenModel) + + composeTestRule + .onNode(matcher = hasScrollToIndexAction()) + .performScrollToIndex(index = 29) + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG) + .assertIsDisplayed() + } + + @Test + fun leavingCreateGroupMode_showsTheCreateGroupButtonAgain() { + val uiState = MutableStateFlow( + NewChatUiState( + recipientPickerUiState = RecipientPickerUiState( + items = contactItems(count = 30), + ), + ), + ) + + setContent(screenModel = createScreenModel(uiStateFlow = uiState)) + + uiState.value = uiState.value.copy(isCreatingGroup = true) + composeTestRule.waitForIdle() + uiState.value = uiState.value.copy(isCreatingGroup = false) + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(testTag = NEW_CHAT_CREATE_GROUP_BUTTON_TEST_TAG) + .assertIsDisplayed() + } + private fun setContent(screenModel: NewChatScreenModel) { composeTestRule.setContent { AppTheme { @@ -233,11 +266,32 @@ class NewChatScreenTest { } } + private fun contactItems(count: Int): ImmutableList { + return persistentListOf( + *Array(size = count) { index -> + val suffix = index.toString().padStart(length = 4, padChar = '0') + + contactItem( + id = index.toLong(), + displayName = "Contact $index", + destination = "+1 555 $suffix", + normalizedDestination = "+1555$suffix", + ) + }, + ) + } + private fun createScreenModel(initialUiState: NewChatUiState): NewChatScreenModel { + return createScreenModel(uiStateFlow = MutableStateFlow(value = initialUiState)) + } + + private fun createScreenModel( + uiStateFlow: MutableStateFlow, + ): NewChatScreenModel { return mockk(relaxed = true) { every { effects } returns MutableSharedFlow() every { navigationEvents } returns MutableSharedFlow() - every { uiState } returns MutableStateFlow(value = initialUiState) + every { uiState } returns uiStateFlow } } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionTest.kt new file mode 100644 index 000000000..9b474e399 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsCopyActionTest.kt @@ -0,0 +1,73 @@ +package com.android.messaging.ui.conversation.messagedetails + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.getOrNull +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.FactoryTestAccess +import com.android.messaging.R +import com.android.messaging.testutil.installTestFactory +import com.android.messaging.ui.core.AppTheme +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +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 MessageDetailsCopyActionTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + installTestFactory(context = targetContext) + } + + @After + fun tearDown() { + FactoryTestAccess.reset() + } + + @Test + fun copyActionIsOfferedOnTheNodeAScreenReaderFocuses() { + val copied = mutableListOf() + + composeTestRule.setContent { + AppTheme { + MessageDetailsStatusSection( + sentTimestamp = null, + receivedTimestamp = RECEIVED_TIMESTAMP, + onCopy = { copied += it }, + ) + } + } + + val label = targetContext.getString(R.string.message_details_received_label) + val focusedNode = composeTestRule.onNodeWithText(label).fetchSemanticsNode() + + val customActions = focusedNode.config.getOrNull(SemanticsActions.CustomActions) + assertNotNull("No custom action on the node holding the row text", customActions) + + assertEquals( + targetContext.getString(R.string.copy_to_clipboard), + customActions!!.single().label, + ) + + customActions.single().action() + + assertEquals( + listOf(formatMessageDetailsTimestamp(timestampMillis = RECEIVED_TIMESTAMP)), + copied, + ) + } + + private companion object { + private const val RECEIVED_TIMESTAMP = 1_700_000_000_000L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderSemanticsTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderSemanticsTest.kt new file mode 100644 index 000000000..c53657421 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageSenderSemanticsTest.kt @@ -0,0 +1,132 @@ +package com.android.messaging.ui.conversation.messages.ui.message.rendering + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.AnnotatedString +import com.android.common.test.helpers.targetContext +import com.android.messaging.R +import com.android.messaging.data.conversation.model.MessageId +import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessageSenderSemanticsTest : BaseConversationMessageRenderingTest() { + + @Test + fun incomingMessageWithoutAVisibleSenderIsStillAnnouncedWithOne() { + setConversationMessageContent( + message = incomingMessage(), + showIncomingParticipantIdentity = false, + ) + + assertEquals( + listOf( + AnnotatedString(text = incomingAnnouncement(sender = SENDER_DISPLAY_NAME)), + AnnotatedString(text = DEFAULT_BODY_TEXT), + ), + bubbleText(), + ) + } + + @Test + fun incomingMessageWithoutASenderNameFallsBackToTheUnknownSender() { + setConversationMessageContent( + message = incomingMessage(senderDisplayName = null), + showIncomingParticipantIdentity = false, + ) + + assertEquals( + listOf( + AnnotatedString( + text = incomingAnnouncement( + sender = targetContext.getString(R.string.unknown_sender), + ), + ), + AnnotatedString(text = DEFAULT_BODY_TEXT), + ), + bubbleText(), + ) + } + + @Test + fun clusteredIncomingMessageIsAnnouncedWithTheSenderItNoLongerShows() { + setConversationMessageContent( + message = incomingMessage(canClusterWithPrevious = true), + showIncomingParticipantIdentity = true, + ) + + assertEquals( + listOf( + AnnotatedString(text = incomingAnnouncement(sender = SENDER_DISPLAY_NAME)), + AnnotatedString(text = DEFAULT_BODY_TEXT), + ), + bubbleText(), + ) + } + + @Test + fun visibleSenderNameIsNotAnnouncedTwice() { + setConversationMessageContent( + message = incomingMessage(), + showIncomingParticipantIdentity = true, + ) + + assertEquals( + listOf( + AnnotatedString(text = SENDER_DISPLAY_NAME), + AnnotatedString(text = DEFAULT_BODY_TEXT), + ), + bubbleText(), + ) + } + + @Test + fun outgoingMessageIsAnnouncedAsSentByTheUser() { + setConversationMessageContent(message = message(isIncoming = false)) + + assertEquals( + listOf( + AnnotatedString( + text = targetContext.getString(R.string.outgoing_sender_content_description), + ), + AnnotatedString(text = DEFAULT_BODY_TEXT), + ), + bubbleText(), + ) + } + + private fun incomingMessage( + senderDisplayName: String? = SENDER_DISPLAY_NAME, + canClusterWithPrevious: Boolean = false, + ): ConversationMessageUiModel { + return message( + status = ConversationMessageUiModel.Status.Incoming.Complete, + isIncoming = true, + senderDisplayName = senderDisplayName, + canClusterWithPrevious = canClusterWithPrevious, + ) + } + + private fun incomingAnnouncement(sender: String): String { + return targetContext.getString(R.string.incoming_sender_content_description, sender) + } + + private fun bubbleText(): List { + return composeTestRule + .onNodeWithTag( + testTag = conversationMessageBubbleTestTag( + messageId = MessageId(DEFAULT_MESSAGE_ID), + ), + ) + .fetchSemanticsNode() + .config[SemanticsProperties.Text] + } + + private companion object { + private const val SENDER_DISPLAY_NAME = "Ada Lovelace" + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt index 6c1642ce7..1e4f0e9f4 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt @@ -261,6 +261,7 @@ class ConversationListViewModelTest { optimisticSnapshotDelegate = optimisticSnapshotDelegate, debugFeaturesProvider = debugFeaturesProvider, resolveContactAction = resolveContactAction, + defaultDispatcher = mainDispatcherRule.testDispatcher, ) } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/HeaderCollapseProgressTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/HeaderCollapseProgressTest.kt new file mode 100644 index 000000000..c9065e60b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/HeaderCollapseProgressTest.kt @@ -0,0 +1,93 @@ +package com.android.messaging.ui.conversationsettings.screen + +import org.junit.Assert.assertEquals +import org.junit.Test + +internal class HeaderCollapseProgressTest { + + @Test + fun headerCollapseProgress_listScrollsFurtherThanHeader_followsScrollOffset() { + val progress = headerCollapseProgress( + scrollOffset = COLLAPSE_DISTANCE / 4, + headerSize = HEADER_SIZE, + maxScrollDistance = HEADER_SIZE * 2, + ) + + assertEquals(0.25f, progress, 0f) + } + + @Test + fun headerCollapseProgress_listScrollsExactlyOneHeader_collapsesFully() { + val progress = headerCollapseProgress( + scrollOffset = HEADER_SIZE, + headerSize = HEADER_SIZE, + maxScrollDistance = HEADER_SIZE, + ) + + assertEquals(1f, progress, 0f) + } + + @Test + fun headerCollapseProgress_listScrollsJustShortOfHeader_collapsesFully() { + val scrollDistance = COLLAPSE_DISTANCE + 1 + + val progress = headerCollapseProgress( + scrollOffset = scrollDistance, + headerSize = HEADER_SIZE, + maxScrollDistance = scrollDistance, + ) + + assertEquals(1f, progress, 0f) + } + + @Test + fun headerCollapseProgress_listScrollsExactlyTheCollapseDistance_collapsesFully() { + val progress = headerCollapseProgress( + scrollOffset = COLLAPSE_DISTANCE, + headerSize = HEADER_SIZE, + maxScrollDistance = COLLAPSE_DISTANCE, + ) + + assertEquals(1f, progress, 0f) + } + + @Test + fun headerCollapseProgress_listScrollsLessThanHeaderCollapseDistance_staysExpanded() { + val progress = headerCollapseProgress( + scrollOffset = 12, + headerSize = HEADER_SIZE, + maxScrollDistance = COLLAPSE_DISTANCE - 1, + ) + + assertEquals(0f, progress, 0f) + } + + @Test + fun headerCollapseProgress_endOfListNotLaidOut_followsScrollOffset() { + val progress = headerCollapseProgress( + scrollOffset = COLLAPSE_DISTANCE / 2, + headerSize = HEADER_SIZE, + maxScrollDistance = null, + ) + + assertEquals(0.5f, progress, 0f) + } + + @Test + fun headerCollapseProgress_headerNotMeasured_staysExpanded() { + val progress = headerCollapseProgress( + scrollOffset = 12, + headerSize = 0, + maxScrollDistance = null, + ) + + assertEquals(0f, progress, 0f) + } + + private companion object { + const val HEADER_SIZE = 400 + + /** Scroll distance over which the cross-fade of a [HEADER_SIZE] header completes. */ + const val COLLAPSE_DISTANCE = 360 + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt new file mode 100644 index 000000000..92dea5d72 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegateImplTest.kt @@ -0,0 +1,159 @@ +package com.android.messaging.ui.conversationsettings.screen.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversation.model.ParticipantId +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversationsettings.model.ConversationSettingsData +import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository +import com.android.messaging.data.conversationsettings.repository.ConversationSettingsRepository +import com.android.messaging.data.subscription.model.SubId +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.domain.conversationsettings.usecase.SetConversationSelfParticipantId +import com.android.messaging.ui.conversationsettings.screen.CONVERSATION_SETTINGS_CONVERSATION_ID_ARG +import com.android.messaging.ui.conversationsettings.screen.mapper.ConversationSettingsUiStateMapperImpl +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationSettingsDelegateImplTest { + + private val settingsRepository = mockk() + private val subscriptionsRepository = mockk() + private val setConversationSelfParticipantId = + mockk(relaxed = true) + + @Test + fun bind_conversationBoundToDefaultSelf_showsDefaultSmsSubscription() { + runTest { + stubConversationSelfParticipantId(ParticipantId(DEFAULT_SELF_PARTICIPANT_ID)) + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.bind(backgroundScope) + runCurrent() + + assertEquals(SECOND_SUBSCRIPTION, delegate.state.value.selectedSubscription) + } + } + + @Test + fun bind_conversationBoundToSubscription_showsThatSubscription() { + runTest { + stubConversationSelfParticipantId(ParticipantId(FIRST_SELF_PARTICIPANT_ID)) + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.bind(backgroundScope) + runCurrent() + + assertEquals(FIRST_SUBSCRIPTION, delegate.state.value.selectedSubscription) + } + } + + @Test + fun setSelfParticipantId_tappingTheImplicitlySelectedSubscription_pinsIt() { + runTest { + stubConversationSelfParticipantId(ParticipantId(DEFAULT_SELF_PARTICIPANT_ID)) + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.bind(backgroundScope) + runCurrent() + delegate.setSelfParticipantId(ParticipantId(SECOND_SELF_PARTICIPANT_ID)) + runCurrent() + + coVerify(exactly = 1) { + setConversationSelfParticipantId( + conversationId = CONVERSATION_ID, + selfParticipantId = ParticipantId(SECOND_SELF_PARTICIPANT_ID), + ) + } + } + } + + @Test + fun setSelfParticipantId_tappingTheAlreadySelectedSubscription_isIgnored() { + runTest { + stubConversationSelfParticipantId(ParticipantId(FIRST_SELF_PARTICIPANT_ID)) + val delegate = createDelegate(applicationScope = backgroundScope) + + delegate.bind(backgroundScope) + runCurrent() + delegate.setSelfParticipantId(ParticipantId(FIRST_SELF_PARTICIPANT_ID)) + runCurrent() + + coVerify(exactly = 0) { setConversationSelfParticipantId(any(), any()) } + } + } + + private fun stubConversationSelfParticipantId(selfParticipantId: ParticipantId) { + every { settingsRepository.getConversationSettings(CONVERSATION_ID) } returns flowOf( + ConversationSettingsData( + conversationId = CONVERSATION_ID, + dbSelfParticipantId = selfParticipantId, + ), + ) + every { subscriptionsRepository.observeActiveSubscriptions() } returns flowOf( + persistentListOf(FIRST_SUBSCRIPTION, SECOND_SUBSCRIPTION), + ) + every { subscriptionsRepository.observeDefaultSmsSubscriptionId() } returns flowOf( + SubId(SECOND_SUB_ID), + ) + } + + private fun createDelegate(applicationScope: CoroutineScope): ConversationSettingsDelegateImpl { + return ConversationSettingsDelegateImpl( + repository = settingsRepository, + notificationRepository = mockk(relaxed = true), + subscriptionsRepository = subscriptionsRepository, + mapper = ConversationSettingsUiStateMapperImpl( + canPlacePhoneCall = { false }, + canShowOrAddContact = { _, _, _, _ -> false }, + isContactSavedUseCase = { _, _ -> false }, + ), + conversationsRepository = mockk(relaxed = true), + blockedParticipantsRepository = mockk(relaxed = true), + setConversationSelfParticipantId = setConversationSelfParticipantId, + applicationScope = applicationScope, + savedStateHandle = SavedStateHandle( + mapOf(CONVERSATION_SETTINGS_CONVERSATION_ID_ARG to CONVERSATION_ID.value), + ), + ) + } + + private companion object { + private val CONVERSATION_ID = ConversationId("conversation-1") + private const val DEFAULT_SELF_PARTICIPANT_ID = "self-participant-default" + private const val FIRST_SELF_PARTICIPANT_ID = "self-participant-1" + private const val SECOND_SELF_PARTICIPANT_ID = "self-participant-2" + private const val FIRST_SUB_ID = 1 + private const val SECOND_SUB_ID = 2 + + private val FIRST_SUBSCRIPTION = Subscription( + selfParticipantId = ParticipantId(FIRST_SELF_PARTICIPANT_ID), + subId = SubId(FIRST_SUB_ID), + label = ConversationSubscriptionLabel.Slot(slotId = 1), + displayDestination = null, + displaySlotId = 1, + color = 0, + ) + private val SECOND_SUBSCRIPTION = Subscription( + selfParticipantId = ParticipantId(SECOND_SELF_PARTICIPANT_ID), + subId = SubId(SECOND_SUB_ID), + label = ConversationSubscriptionLabel.Slot(slotId = 2), + displayDestination = null, + displaySlotId = 2, + color = 0, + ) + } +} 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 ab4bf3da3..84e8d514a 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 @@ -1,7 +1,11 @@ package com.android.messaging.ui.conversationsettings.screen.mapper import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.data.conversation.model.ParticipantId +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel import com.android.messaging.data.conversationsettings.model.ConversationSettingsData +import com.android.messaging.data.subscription.model.SubId +import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.ui.conversationsettings.screen.model.ParticipantUiState import io.mockk.every @@ -41,6 +45,48 @@ internal class ConversationSettingsUiStateMapperImplTest { assertEquals(DISPLAY_DESTINATION, participantUiState.details) } + @Test + fun map_conversationBoundToDefaultSelf_selectsDefaultSmsSubscription() { + val uiState = mapper.map( + data = ConversationSettingsData( + conversationId = CONVERSATION_ID, + dbSelfParticipantId = ParticipantId(DEFAULT_SELF_PARTICIPANT_ID), + ), + subscriptions = persistentListOf(FIRST_SUBSCRIPTION, SECOND_SUBSCRIPTION), + defaultSmsSubscriptionId = SubId(SECOND_SUB_ID), + ) + + assertEquals(SECOND_SUBSCRIPTION, uiState.selectedSubscription) + } + + @Test + fun map_conversationBoundToSubscription_selectsThatSubscription() { + val uiState = mapper.map( + data = ConversationSettingsData( + conversationId = CONVERSATION_ID, + dbSelfParticipantId = ParticipantId(FIRST_SELF_PARTICIPANT_ID), + ), + subscriptions = persistentListOf(FIRST_SUBSCRIPTION, SECOND_SUBSCRIPTION), + defaultSmsSubscriptionId = SubId(SECOND_SUB_ID), + ) + + assertEquals(FIRST_SUBSCRIPTION, uiState.selectedSubscription) + } + + @Test + fun map_conversationBoundToDefaultSelf_keepsUnresolvedSelfParticipantId() { + val uiState = mapper.map( + data = ConversationSettingsData( + conversationId = CONVERSATION_ID, + dbSelfParticipantId = ParticipantId(DEFAULT_SELF_PARTICIPANT_ID), + ), + subscriptions = persistentListOf(FIRST_SUBSCRIPTION, SECOND_SUBSCRIPTION), + defaultSmsSubscriptionId = SubId(SECOND_SUB_ID), + ) + + assertEquals(ParticipantId(DEFAULT_SELF_PARTICIPANT_ID), uiState.selfParticipantId) + } + private fun mapParticipant( name: String?, unknownSender: Boolean, @@ -54,10 +100,12 @@ internal class ConversationSettingsUiStateMapperImplTest { return mapper .map( - ConversationSettingsData( + data = ConversationSettingsData( conversationId = CONVERSATION_ID, participants = persistentListOf(participant), ), + subscriptions = persistentListOf(), + defaultSmsSubscriptionId = SubId(ParticipantData.DEFAULT_SELF_SUB_ID), ) .participants .single() @@ -68,5 +116,27 @@ internal class ConversationSettingsUiStateMapperImplTest { private const val SEND_DESTINATION = "+15550123" private const val DISPLAY_DESTINATION = "+1 555-0123" private const val FULL_NAME = "Ada Lovelace" + private const val DEFAULT_SELF_PARTICIPANT_ID = "self-participant-default" + private const val FIRST_SELF_PARTICIPANT_ID = "self-participant-1" + private const val SECOND_SELF_PARTICIPANT_ID = "self-participant-2" + private const val FIRST_SUB_ID = 1 + private const val SECOND_SUB_ID = 2 + + private val FIRST_SUBSCRIPTION = Subscription( + selfParticipantId = ParticipantId(FIRST_SELF_PARTICIPANT_ID), + subId = SubId(FIRST_SUB_ID), + label = ConversationSubscriptionLabel.Slot(slotId = 1), + displayDestination = null, + displaySlotId = 1, + color = 0, + ) + private val SECOND_SUBSCRIPTION = Subscription( + selfParticipantId = ParticipantId(SECOND_SELF_PARTICIPANT_ID), + subId = SubId(SECOND_SUB_ID), + label = ConversationSubscriptionLabel.Slot(slotId = 2), + displayDestination = null, + displaySlotId = 2, + color = 0, + ) } } diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt index 86fe25645..dc6514ec0 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -1,11 +1,13 @@ package com.android.messaging.data.conversation.mapper +import androidx.core.net.toUri import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.util.LogUtil +import com.android.messaging.util.UriUtil import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList @@ -43,8 +45,8 @@ internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } return when { - isPhotoPickerUri(contentUri) -> { - LogUtil.w(TAG, "Dropping draft attachment backed by photo picker URI") + isMediaStoreUri(contentUri) -> { + LogUtil.w(TAG, "Dropping draft attachment backed by MediaStore URI") null } @@ -73,12 +75,18 @@ internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } } - private fun isPhotoPickerUri(uri: String?): Boolean { - return uri?.startsWith(prefix = PHOTO_PICKER_URI_PREFIX) == true + /** + * The app holds no media read permissions, so a MediaStore URI is unreadable unless a grant + * came with it, and a grant never survives into a persisted draft. No live path stores one + * anyway - every picker copies its selection into scratch space first - so these only turn up + * in drafts written by the pre-Compose gallery picker. Drop them rather than restore an + * attachment that can only fail. + */ + private fun isMediaStoreUri(uri: String?): Boolean { + return uri != null && UriUtil.isMediaStoreUri(uri.toUri()) } private companion object { private const val TAG = "ConversationMsgDataDraftMapper" - private const val PHOTO_PICKER_URI_PREFIX = "content://media/picker/" } } diff --git a/src/com/android/messaging/data/media/model/ConversationMediaItem.kt b/src/com/android/messaging/data/media/model/ConversationMediaItem.kt deleted file mode 100644 index 7f5fcea62..000000000 --- a/src/com/android/messaging/data/media/model/ConversationMediaItem.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.android.messaging.data.media.model - -internal data class ConversationMediaItem( - val mediaId: String, - val contentUri: String, - val contentType: String, - val width: Int?, - val height: Int?, - val durationMillis: Long?, -) diff --git a/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt b/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt deleted file mode 100644 index 3f8a16665..000000000 --- a/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.android.messaging.data.media.repository - -import android.content.ContentResolver -import android.os.Bundle -import android.provider.MediaStore -import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.di.core.IoDispatcher -import com.android.messaging.util.ContentType -import com.android.messaging.util.UriUtil -import com.android.messaging.util.core.extension.typedFlow -import javax.inject.Inject -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOn - -internal interface ConversationMediaRepository { - fun getRecentMedia(limit: Int = DEFAULT_RECENT_MEDIA_LIMIT): Flow> - - private companion object { - private const val DEFAULT_RECENT_MEDIA_LIMIT = 200 - } -} - -internal class ConversationMediaRepositoryImpl @Inject constructor( - private val contentResolver: ContentResolver, - @param:IoDispatcher - private val ioDispatcher: CoroutineDispatcher, -) : ConversationMediaRepository { - - override fun getRecentMedia(limit: Int): Flow> { - return typedFlow { - queryRecentMedia(limit = limit) - }.flowOn(context = ioDispatcher) - } - - private fun queryRecentMedia(limit: Int): List { - return contentResolver.query( - MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), - RECENT_MEDIA_PROJECTION, - createRecentMediaQueryArgs(limit = limit), - null, - )?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) - val mediaTypeIndex = cursor.getColumnIndexOrThrow( - MediaStore.Files.FileColumns.MEDIA_TYPE, - ) - val mimeTypeIndex = cursor.getColumnIndexOrThrow( - MediaStore.Files.FileColumns.MIME_TYPE, - ) - val widthIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.WIDTH) - val heightIndex = cursor.getColumnIndexOrThrow( - MediaStore.Files.FileColumns.HEIGHT, - ) - val durationIndex = cursor.getColumnIndexOrThrow( - MediaStore.Video.VideoColumns.DURATION, - ) - - buildList(capacity = cursor.count) { - while (cursor.moveToNext()) { - val mediaStoreId = cursor.getLong(idIndex) - val mediaTypeValue = cursor.getInt(mediaTypeIndex) - - val item = ConversationMediaItem( - mediaId = mediaStoreId.toString(), - contentUri = UriUtil - .getContentUriForMediaStoreId(mediaStoreId) - .toString(), - contentType = cursor - .getString(mimeTypeIndex) - ?.takeIf { it.isNotBlank() } - ?: fallbackContentType(mediaTypeValue = mediaTypeValue), - width = cursor.getInt(widthIndex).takeIf { it > 0 }, - height = cursor.getInt(heightIndex).takeIf { it > 0 }, - durationMillis = cursor.getLong(durationIndex).takeIf { it > 0 }, - ) - - add(item) - } - } - }.orEmpty() - } - - private fun createRecentMediaQueryArgs(limit: Int): Bundle { - return Bundle().apply { - putString( - ContentResolver.QUERY_ARG_SQL_SELECTION, - RECENT_MEDIA_SELECTION, - ) - putStringArray( - ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf(MediaStore.Files.FileColumns.DATE_ADDED), - ) - putInt( - ContentResolver.QUERY_ARG_SORT_DIRECTION, - ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, - ) - putInt(ContentResolver.QUERY_ARG_LIMIT, limit) - } - } - - private fun fallbackContentType(mediaTypeValue: Int): String { - return when (mediaTypeValue) { - MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> ContentType.VIDEO_UNSPECIFIED - else -> ContentType.IMAGE_UNSPECIFIED - } - } - - private companion object { - private val RECENT_MEDIA_PROJECTION: Array = arrayOf( - MediaStore.Files.FileColumns._ID, - MediaStore.Files.FileColumns.MEDIA_TYPE, - MediaStore.Files.FileColumns.MIME_TYPE, - MediaStore.Files.FileColumns.DATE_ADDED, - MediaStore.Files.FileColumns.WIDTH, - MediaStore.Files.FileColumns.HEIGHT, - MediaStore.Video.VideoColumns.DURATION, - ) - - private val RECENT_MEDIA_SELECTION: String by lazy { - buildString { - append(MediaStore.Files.FileColumns.MEDIA_TYPE) - append(" IN (") - append(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) - append(",") - append(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) - append(")") - } - } - } -} diff --git a/src/com/android/messaging/datamodel/BugleNotifications.java b/src/com/android/messaging/datamodel/BugleNotifications.java index 9e7197682..f1783bf20 100644 --- a/src/com/android/messaging/datamodel/BugleNotifications.java +++ b/src/com/android/messaging/datamodel/BugleNotifications.java @@ -132,6 +132,9 @@ public class BugleNotifications { private static final AtomicLong sLastNotificationImageSweep = new AtomicLong(); + /** Guards a notification pass, so that concurrent callers do not repeat each other's work */ + private static final Object sNotificationLock = new Object(); + /** * This is the volume at which to play the observable-conversation notification sound, * expressed as a fraction of the system notification volume. @@ -162,6 +165,12 @@ public static void update(final String conversationId, final int coverage) { } Assert.isNotMainThread(); + synchronized (sNotificationLock) { + updateSerialized(conversationId, coverage); + } + } + + private static void updateSerialized(final String conversationId, final int coverage) { final long passStart = System.currentTimeMillis(); try { if (!PhoneUtils.getDefault().isDefaultSmsApp()) { @@ -733,12 +742,21 @@ public static Bitmap getAvatarBitmap(Context context, Uri avatarUri) { return null; } - static Uri getNotificationImageUri(final Context context, final Uri imageUri) { + /** + * Transcodes the attachment at {@code imageUri} into a file named after {@code partId}, or + * returns the file an earlier call already wrote for that part. Call it from a notification + * pass only: the deterministic name makes the temporary file it writes through deterministic + * too, so two concurrent callers for one part would trample each other's write. + */ + static Uri getNotificationImageUri(final Context context, final Uri imageUri, + final String partId) { if (imageUri == null) { return null; } - final Uri notificationImageUri = NotificationImageProvider.buildNotificationImageUri(); + final Uri notificationImageUri = NotificationImageProvider + .buildNotificationImageUri(partId); + if (notificationImageUri == null) { return null; } @@ -747,6 +765,11 @@ static Uri getNotificationImageUri(final Context context, final Uri imageUri) { return null; } + if (imageFile.length() > 0) { + imageFile.setLastModified(System.currentTimeMillis()); + return notificationImageUri; + } + final ImageRequestDescriptor descriptor = new UriImageRequestDescriptor( imageUri, NOTIFICATION_IMAGE_MAX_SIZE, diff --git a/src/com/android/messaging/datamodel/DataModel.java b/src/com/android/messaging/datamodel/DataModel.java index d2879c2fc..51548d82f 100644 --- a/src/com/android/messaging/datamodel/DataModel.java +++ b/src/com/android/messaging/datamodel/DataModel.java @@ -29,10 +29,8 @@ import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.GalleryGridItemData; import com.android.messaging.datamodel.data.LaunchConversationData; import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; -import com.android.messaging.datamodel.data.MediaPickerData; import com.android.messaging.util.Assert.DoesNotRunOnMainThread; public abstract class DataModel { @@ -63,10 +61,6 @@ public abstract ConversationListData createConversationListData(final Context co public abstract ConversationData createConversationData(final Context context, final ConversationDataListener listener, final String conversationId); - public abstract MediaPickerData createMediaPickerData(final Context context); - - public abstract GalleryGridItemData createGalleryGridItemData(); - public abstract LaunchConversationData createLaunchConversationData( LaunchConversationDataListener listener); diff --git a/src/com/android/messaging/datamodel/DataModelImpl.java b/src/com/android/messaging/datamodel/DataModelImpl.java index a35cb2db3..4c9f21708 100644 --- a/src/com/android/messaging/datamodel/DataModelImpl.java +++ b/src/com/android/messaging/datamodel/DataModelImpl.java @@ -29,10 +29,8 @@ import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.GalleryGridItemData; import com.android.messaging.datamodel.data.LaunchConversationData; import com.android.messaging.datamodel.data.LaunchConversationData.LaunchConversationDataListener; -import com.android.messaging.datamodel.data.MediaPickerData; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.sms.MmsConfig; import com.android.messaging.util.Assert; @@ -75,16 +73,6 @@ public ConversationData createConversationData(final Context context, return new ConversationData(context, listener, conversationId); } - @Override - public MediaPickerData createMediaPickerData(final Context context) { - return new MediaPickerData(context); - } - - @Override - public GalleryGridItemData createGalleryGridItemData() { - return new GalleryGridItemData(); - } - @Override public LaunchConversationData createLaunchConversationData( final LaunchConversationDataListener listener) { diff --git a/src/com/android/messaging/datamodel/DatabaseHelper.java b/src/com/android/messaging/datamodel/DatabaseHelper.java index 0dde72c1b..7c5a0c418 100644 --- a/src/com/android/messaging/datamodel/DatabaseHelper.java +++ b/src/com/android/messaging/datamodel/DatabaseHelper.java @@ -33,6 +33,7 @@ import com.android.messaging.util.LogUtil; import com.google.common.annotations.VisibleForTesting; +import java.io.File; import java.util.ArrayList; import java.util.List; @@ -812,6 +813,14 @@ private static void dropAllIndexes(final SQLiteDatabase db) { } private static void createDatabase(final SQLiteDatabase db) { + // A fresh parts table starts its _id at 1 again, whether this is a rebuild, a first + // run, or the recreation that follows corruption. The notification images are named + // after those ids and outlive the database, so leaving them behind would serve one + // part's image as the image of whatever part next takes its id + for (final File image : NotificationImageProvider.listImageFiles()) { + image.delete(); + } + for (final String sql : CREATE_TABLE_SQLS) { db.execSQL(sql); } diff --git a/src/com/android/messaging/datamodel/FileProvider.java b/src/com/android/messaging/datamodel/FileProvider.java index ee332cd11..5556068a7 100644 --- a/src/com/android/messaging/datamodel/FileProvider.java +++ b/src/com/android/messaging/datamodel/FileProvider.java @@ -80,10 +80,23 @@ protected static boolean ensureFileExists(final File file) { * @return unique uri that can be used to write temporary files */ protected static Uri buildFileUri(final String authority, final String extension) { - final long fileId = Math.abs(RANDOM_ID.nextLong()); + return buildFileUri(authority, extension, + String.valueOf(Math.abs(RANDOM_ID.nextLong()))); + } + + /** + * Build uri for a file with a caller-chosen id, so that the same logical content maps to the + * same file on every call. The id must satisfy {@link #isValidFileId}. + * @param authority authority with which to populate uri + * @param extension optional file extension + * @param fileId the file id to use + * @return uri naming that file + */ + protected static Uri buildFileUri(final String authority, final String extension, + final String fileId) { final Uri.Builder builder = (new Uri.Builder()).authority(authority).scheme( ContentResolver.SCHEME_CONTENT); - builder.appendPath(String.valueOf(fileId)); + builder.appendPath(fileId); if (!TextUtils.isEmpty(extension)) { builder.appendQueryParameter(FILE_EXTENSION_PARAM_KEY, extension); } diff --git a/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java b/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java deleted file mode 100644 index 1974a5e7a..000000000 --- a/src/com/android/messaging/datamodel/GalleryBoundCursorLoader.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.datamodel; - -import android.content.Context; -import android.net.Uri; -import android.provider.MediaStore.Files; -import android.provider.MediaStore.Files.FileColumns; -import android.provider.MediaStore.MediaColumns; - -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.google.common.base.Joiner; - -/** - * A BoundCursorLoader that reads local media on the device. - */ -public class GalleryBoundCursorLoader extends BoundCursorLoader { - public static final String MEDIA_SCANNER_VOLUME_EXTERNAL = "external"; - private static final Uri STORAGE_URI = Files.getContentUri(MEDIA_SCANNER_VOLUME_EXTERNAL); - private static final String SORT_ORDER = MediaColumns.DATE_MODIFIED + " DESC"; - private static final String SELECTION = createSelection( - MessagePartData.ACCEPTABLE_GALLERY_MEDIA_TYPES, - new Integer[] { - FileColumns.MEDIA_TYPE_IMAGE, - FileColumns.MEDIA_TYPE_VIDEO, - FileColumns.MEDIA_TYPE_AUDIO - }); - - public GalleryBoundCursorLoader(final String bindingId, final Context context) { - super(bindingId, context, STORAGE_URI, GalleryGridItemData.MEDIA_PROJECTION, SELECTION, - null, SORT_ORDER); - } - - private static String createSelection(final String[] mimeTypes, Integer[] mediaTypes) { - return MediaColumns.MIME_TYPE + " IN ('" + Joiner.on("','").join(mimeTypes) + "') AND " - + FileColumns.MEDIA_TYPE + " IN (" + Joiner.on(',').join(mediaTypes) + ")"; - } -} diff --git a/src/com/android/messaging/datamodel/MessageNotificationState.java b/src/com/android/messaging/datamodel/MessageNotificationState.java index 7407ed09d..87845101f 100644 --- a/src/com/android/messaging/datamodel/MessageNotificationState.java +++ b/src/com/android/messaging/datamodel/MessageNotificationState.java @@ -122,11 +122,12 @@ public static class MessageLineInfo { final Uri mAvatarUri; final Uri mAttachmentUri; final String mAttachmentType; + final String mAttachmentPartId; final String mContactUriString; MessageLineInfo(final String authorId, final String authorFullName, final String authorFirstName, final CharSequence text, final Uri attachmentUri, - final String attachmentType, + final String attachmentType, final String attachmentPartId, final boolean isManualDownloadNeeded, final Uri avatarUri, final String messageId, final long timestamp, final String contactUriString) { mAuthorId = authorId; @@ -150,6 +151,7 @@ public static class MessageLineInfo { mAvatarUri = avatarUri; mAttachmentUri = attachmentUri; mAttachmentType = attachmentType; + mAttachmentPartId = attachmentPartId; mContactUriString = contactUriString; } @@ -195,7 +197,8 @@ public MessagingStyle.Message createStyledMessage(Person person) { new MessagingStyle.Message(mText, mTimestamp, person); if (mAttachmentUri != null && ContentType.isImageType(mAttachmentType)) { final Uri notificationImageUri = BugleNotifications.getNotificationImageUri( - Factory.get().getApplicationContext(), mAttachmentUri); + Factory.get().getApplicationContext(), mAttachmentUri, + mAttachmentPartId); if (notificationImageUri != null) { message.setData(ContentType.IMAGE_JPEG, notificationImageUri); } @@ -552,11 +555,13 @@ private static ConversationsList createConversationsList() { // Otherwise, show the first one we find. Uri attachmentUri = null; String attachmentType = null; + String attachmentPartId = null; final MessagePartData messagePartData = getMostInterestingAttachment(convMessageData); if (messagePartData != null) { attachmentUri = messagePartData.getContentUri(); attachmentType = messagePartData.getContentType(); + attachmentPartId = messagePartData.getPartId(); } Uri contactUri = convMessageData.getSenderContactLookupUri(); @@ -567,7 +572,7 @@ private static ConversationsList createConversationsList() { conversation.mLineInfos.add(new MessageLineInfo(authorId, authorFullName, authorFirstName, text, - attachmentUri, attachmentType, + attachmentUri, attachmentType, attachmentPartId, isManualDownloadNeeded, avatarUri, messageId, timestamp, contactUriString)); messageCount++; diff --git a/src/com/android/messaging/datamodel/NotificationImageProvider.kt b/src/com/android/messaging/datamodel/NotificationImageProvider.kt index eeb3f283d..6d9549879 100644 --- a/src/com/android/messaging/datamodel/NotificationImageProvider.kt +++ b/src/com/android/messaging/datamodel/NotificationImageProvider.kt @@ -31,21 +31,21 @@ class NotificationImageProvider : FileProvider() { } @JvmStatic - fun buildNotificationImageUri(): Uri? { - val uri = buildFileUri(AUTHORITY, NOTIFICATION_IMAGE_EXTENSION) - val file = getFileFromUri(uri) - - return when { - file == null -> null - ensureFileExists(file) -> uri - else -> { - LogUtil.e( - LogUtil.BUGLE_TAG, - "Failed to create notification image ${file.absolutePath}", - ) - null - } + fun buildNotificationImageUri(fileId: String?): Uri? { + val directory = getDirectory() + if (!directory.exists() && !directory.mkdirs()) { + LogUtil.e(LogUtil.BUGLE_TAG, "Failed to create ${directory.absolutePath}") + return null } + + val uri = when { + !fileId.isNullOrEmpty() && isValidFileId(fileId) -> + buildFileUri(AUTHORITY, NOTIFICATION_IMAGE_EXTENSION, fileId) + + else -> buildFileUri(AUTHORITY, NOTIFICATION_IMAGE_EXTENSION) + } + + return uri.takeIf { getFileFromUri(it) != null } } @JvmStatic diff --git a/src/com/android/messaging/datamodel/data/GalleryGridItemData.java b/src/com/android/messaging/datamodel/data/GalleryGridItemData.java deleted file mode 100644 index 941d38d4e..000000000 --- a/src/com/android/messaging/datamodel/data/GalleryGridItemData.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.datamodel.data; - -import android.database.Cursor; -import android.graphics.Rect; -import android.net.Uri; -import android.provider.BaseColumns; -import android.provider.MediaStore.MediaColumns; -import android.text.TextUtils; - -import com.android.messaging.datamodel.media.FileImageRequestDescriptor; -import com.android.messaging.datamodel.media.ImageRequest; -import com.android.messaging.datamodel.media.UriImageRequestDescriptor; -import com.android.messaging.datamodel.media.VideoThumbnailRequestDescriptor; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.UriUtil; - -/** - * Provides data for GalleryGridItemView - */ -public class GalleryGridItemData { - public static final String[] MEDIA_PROJECTION = new String[] { - MediaColumns._ID, - MediaColumns.DATA, - MediaColumns.WIDTH, - MediaColumns.HEIGHT, - MediaColumns.MIME_TYPE, - MediaColumns.DATE_MODIFIED, - MediaColumns.DISPLAY_NAME}; - - public static final String[] SPECIAL_ITEM_COLUMNS = new String[] { - BaseColumns._ID - }; - - private static final int INDEX_ID = 0; - - // For local media gallery. - private static final int INDEX_DATA_PATH = 1; - private static final int INDEX_WIDTH = 2; - private static final int INDEX_HEIGHT = 3; - private static final int INDEX_MIME_TYPE = 4; - private static final int INDEX_DATE_MODIFIED = 5; - private static final int INDEX_DISPLAY_NAME = 6; - - /** A special item's id for picking a media from document picker */ - public static final String ID_DOCUMENT_PICKER_ITEM = "-1"; - - private UriImageRequestDescriptor mImageData; - private String mContentType; - private boolean mIsDocumentPickerItem; - private long mDateSeconds; - private String mFileName; - private Uri mAudioUri; - - public GalleryGridItemData() { - } - - public void bind(final Cursor cursor, final int desiredWidth, final int desiredHeight) { - mIsDocumentPickerItem = TextUtils.equals(cursor.getString(INDEX_ID), - ID_DOCUMENT_PICKER_ITEM); - if (mIsDocumentPickerItem) { - mImageData = null; - mContentType = null; - } else { - mContentType = cursor.getString(INDEX_MIME_TYPE); - final String filePath = cursor.getString(INDEX_DATA_PATH); - final String dateModified = cursor.getString(INDEX_DATE_MODIFIED); - mDateSeconds = !TextUtils.isEmpty(dateModified) ? Long.parseLong(dateModified) : -1; - if (ContentType.isAudioType(mContentType)) { - mImageData = null; - mAudioUri = UriUtil.getUriForResourceFile(filePath); - mFileName = cursor.getString(INDEX_DISPLAY_NAME); - } else { // For image and video types - int sourceWidth = cursor.getInt(INDEX_WIDTH); - int sourceHeight = cursor.getInt(INDEX_HEIGHT); - - // Guard against bad data - if (sourceWidth <= 0) { - sourceWidth = ImageRequest.UNSPECIFIED_SIZE; - } - if (sourceHeight <= 0) { - sourceHeight = ImageRequest.UNSPECIFIED_SIZE; - } - - if (ContentType.isVideoType(mContentType)) { - mImageData = new VideoThumbnailRequestDescriptor( - cursor.getLong(INDEX_ID), - desiredWidth, - desiredHeight, - sourceWidth, - sourceHeight); - } else { - mImageData = new FileImageRequestDescriptor( - filePath, - desiredWidth, - desiredHeight, - sourceWidth, - sourceHeight, - true /* canUseThumbnail */, - true /* allowCompression */, - true /* isStatic */); - } - } - } - } - - public boolean isDocumentPickerItem() { - return mIsDocumentPickerItem; - } - - public Uri getImageUri() { - return ContentType.isAudioType(mContentType) ? mAudioUri : mImageData.uri; - } - - public UriImageRequestDescriptor getImageRequestDescriptor() { - return mImageData; - } - - public MessagePartData constructMessagePartData(final Rect startRect) { - Assert.isTrue(!mIsDocumentPickerItem); - return ContentType.isAudioType(mContentType) - ? new MediaPickerMessagePartData(startRect, mContentType, mAudioUri, 0, 0) - : new MediaPickerMessagePartData(startRect, mContentType, mImageData.uri, - mImageData.sourceWidth, mImageData.sourceHeight); - } - - /** - * @return The date in seconds. This can be negative if we could not retreive date info - */ - public long getDateSeconds() { - return mDateSeconds; - } - - public String getContentType() { - return mContentType; - } - - public String getFileName() { - return mFileName; - } -} diff --git a/src/com/android/messaging/datamodel/data/MediaPickerData.java b/src/com/android/messaging/datamodel/data/MediaPickerData.java deleted file mode 100644 index 015d2e740..000000000 --- a/src/com/android/messaging/datamodel/data/MediaPickerData.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.datamodel.data; - -import android.content.Context; -import android.database.Cursor; -import android.os.Bundle; -import androidx.annotation.Nullable; -import androidx.loader.app.LoaderManager; -import androidx.loader.content.Loader; - -import com.android.messaging.datamodel.BoundCursorLoader; -import com.android.messaging.datamodel.GalleryBoundCursorLoader; -import com.android.messaging.datamodel.binding.BindableData; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.util.Assert; -import com.android.messaging.util.BuglePrefs; -import com.android.messaging.util.BuglePrefsKeys; -import com.android.messaging.util.LogUtil; - -/** - * Services data needs for MediaPicker. - */ -public class MediaPickerData extends BindableData { - public interface MediaPickerDataListener { - void onMediaPickerDataUpdated(MediaPickerData mediaPickerData, Object data, int loaderId); - } - - private static final String BINDING_ID = "bindingId"; - private final Context mContext; - private LoaderManager mLoaderManager; - private final GalleryLoaderCallbacks mGalleryLoaderCallbacks; - private MediaPickerDataListener mListener; - - public MediaPickerData(final Context context) { - mContext = context; - mGalleryLoaderCallbacks = new GalleryLoaderCallbacks(); - } - - public static final int GALLERY_MEDIA_LOADER = 1; - - /** - * A trampoline class so that we can inherit from LoaderManager.LoaderCallbacks multiple times. - */ - private class GalleryLoaderCallbacks extends CursorLoaderCallbacks { - @Override - public Loader onCreateLoader(final int id, final Bundle args) { - final String bindingId = args.getString(BINDING_ID); - // Check if data still bound to the requesting ui element - if (isBound(bindingId)) { - switch (id) { - case GALLERY_MEDIA_LOADER: - return new GalleryBoundCursorLoader(bindingId, mContext); - - default: - Assert.fail("Unknown loader id for gallery picker!"); - break; - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding the media picker"); - } - return null; - } - - /** - * {@inheritDoc} - */ - @Override - public void onLoadFinish(final Loader loader, final Cursor data) { - final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; - if (isBound(cursorLoader.getBindingId())) { - switch (loader.getId()) { - case GALLERY_MEDIA_LOADER: - mListener.onMediaPickerDataUpdated(MediaPickerData.this, data, - GALLERY_MEDIA_LOADER); - break; - - default: - Assert.fail("Unknown loader id for gallery picker!"); - break; - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding the media picker"); - } - } - - /** - * {@inheritDoc} - */ - @Override - public void onLoaderReset(final Loader loader) { - final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; - if (isBound(cursorLoader.getBindingId())) { - switch (loader.getId()) { - case GALLERY_MEDIA_LOADER: - mListener.onMediaPickerDataUpdated(MediaPickerData.this, null, - GALLERY_MEDIA_LOADER); - break; - - default: - Assert.fail("Unknown loader id for media picker!"); - break; - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding the media picker"); - } - } - } - - - - public void startLoader(final int loaderId, final BindingBase binding, - @Nullable Bundle args, final MediaPickerDataListener listener) { - if (args == null) { - args = new Bundle(); - } - args.putString(BINDING_ID, binding.getBindingId()); - if (loaderId == GALLERY_MEDIA_LOADER) { - mLoaderManager.initLoader(loaderId, args, mGalleryLoaderCallbacks).forceLoad(); - } else { - Assert.fail("Unsupported loader id for media picker!"); - } - mListener = listener; - } - - public void destroyLoader(final int loaderId) { - mLoaderManager.destroyLoader(loaderId); - } - - public void init(final LoaderManager loaderManager) { - mLoaderManager = loaderManager; - } - - @Override - protected void unregisterListeners() { - // This could be null if we bind but the caller doesn't init the BindableData - if (mLoaderManager != null) { - mLoaderManager.destroyLoader(GALLERY_MEDIA_LOADER); - mLoaderManager = null; - } - } - - /** - * Gets the last selected chooser index, or -1 if no selection has been saved. - */ - public int getSelectedChooserIndex() { - return BuglePrefs.getApplicationPrefs().getInt( - BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, - BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX_DEFAULT); - } - - /** - * Saves the selected media chooser index. - * @param selectedIndex the selected media chooser index. - */ - public void saveSelectedChooserIndex(final int selectedIndex) { - BuglePrefs.getApplicationPrefs().putInt(BuglePrefsKeys.SELECTED_MEDIA_PICKER_CHOOSER_INDEX, - selectedIndex); - } - -} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java deleted file mode 100644 index 22f187131..000000000 --- a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.datamodel.media; - -import android.content.Context; - -import com.android.messaging.util.ImageUtils; -import com.android.messaging.util.UriUtil; - -public class VideoThumbnailRequestDescriptor extends UriImageRequestDescriptor { - public VideoThumbnailRequestDescriptor(final long id, int desiredWidth, int desiredHeight, - int sourceWidth, int sourceHeight) { - super(UriUtil.getContentUriForMediaStoreId(id), desiredWidth, desiredHeight, sourceWidth, - sourceHeight, false /* canCompress */, false /* isStatic */, - false /* cropToCircle */, - ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, - ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); - } - - @Override - public MediaRequest buildSyncMediaRequest(Context context) { - return new VideoThumbnailRequest(context, this); - } -} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index e662f526c..dea6d39bf 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -34,8 +34,6 @@ import com.android.messaging.data.conversation.store.ConversationSelfIdStore import com.android.messaging.data.conversation.store.ConversationSelfIdStoreImpl import com.android.messaging.data.media.repository.ConversationAttachmentsRepository import com.android.messaging.data.media.repository.ConversationAttachmentsRepositoryImpl -import com.android.messaging.data.media.repository.ConversationMediaRepository -import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.data.subscription.repository.ConversationSimSelectionRepository import com.android.messaging.data.subscription.repository.ConversationSimSelectionRepositoryImpl import com.android.messaging.data.subscription.repository.SubscriptionsRepository @@ -367,12 +365,6 @@ internal abstract class ConversationBindsModule { impl: ConversationVCardMetadataMapperImpl, ): ConversationVCardMetadataMapper - @Binds - @Reusable - abstract fun bindConversationMediaRepository( - impl: ConversationMediaRepositoryImpl, - ): ConversationMediaRepository - @Binds abstract fun bindConversationMetadataUiStateMapper( impl: ConversationMetadataUiStateMapperImpl, diff --git a/src/com/android/messaging/mmslib/pdu/PduPersister.java b/src/com/android/messaging/mmslib/pdu/PduPersister.java index e4f4d7127..83d7d33b2 100644 --- a/src/com/android/messaging/mmslib/pdu/PduPersister.java +++ b/src/com/android/messaging/mmslib/pdu/PduPersister.java @@ -973,6 +973,9 @@ private void persistData(final PduPart part, final Uri uri, } catch (final FileNotFoundException e) { Log.e(TAG, "Failed to open Input/Output stream.", e); throw new MmsException(e); + } catch (final SecurityException e) { + Log.e(TAG, "Not allowed to read " + dataUri, e); + throw new MmsException(e); } catch (final IOException e) { Log.e(TAG, "Failed to read/write data.", e); throw new MmsException(e); diff --git a/src/com/android/messaging/receiver/SmsDeliverReceiver.kt b/src/com/android/messaging/receiver/SmsDeliverReceiver.kt index 5303ab89c..076e12914 100644 --- a/src/com/android/messaging/receiver/SmsDeliverReceiver.kt +++ b/src/com/android/messaging/receiver/SmsDeliverReceiver.kt @@ -7,7 +7,9 @@ import android.provider.Telephony.Sms import android.telephony.SmsMessage import com.android.messaging.di.receiver.IncomingSmsEntryPoint import dagger.hilt.android.EntryPointAccessors +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull class SmsDeliverReceiver : BroadcastReceiver() { @@ -21,8 +23,14 @@ class SmsDeliverReceiver : BroadcastReceiver() { val appContext = context.applicationContext val entryPoint = entryPoint(appContext) entryPoint.applicationScope().launch(entryPoint.ioDispatcher()) { - try { + val delivery = launch { entryPoint.incomingSmsDeliverer().deliverFromIntent(appContext, intent) + } + try { + // The import keeps running in the application scope either way; stop holding the + // broadcast open for it once the window is nearly spent, or the system kills us + // with an ANR and the import does not finish at all. + withTimeoutOrNull(BROADCAST_BUDGET) { delivery.join() } } finally { pendingResult.finish() } @@ -31,6 +39,8 @@ class SmsDeliverReceiver : BroadcastReceiver() { companion object { + private val BROADCAST_BUDGET = 8.seconds + @JvmStatic fun deliverSmsMessages( context: Context, diff --git a/src/com/android/messaging/sms/MmsUtils.java b/src/com/android/messaging/sms/MmsUtils.java index 5a71dc42d..9cb3bd01e 100644 --- a/src/com/android/messaging/sms/MmsUtils.java +++ b/src/com/android/messaging/sms/MmsUtils.java @@ -2175,6 +2175,10 @@ private static SendReq createMmsSendReq(final Context context, final int subId, requireReadReport, expiryTime, priority, timestampMillis); } catch (final InvalidHeaderValueException e) { LogUtil.e(TAG, "InvalidHeaderValue creating sendReq PDU"); + } catch (final SecurityException e) { + // An attachment uri we can no longer read, e.g. a MediaStore uri left in an old + // draft. Fail the send cleanly instead of taking the process down. + LogUtil.e(TAG, "Not allowed to read attachment creating sendReq PDU", e); } catch (final OutOfMemoryError e) { LogUtil.e(TAG, "Out of memory error creating sendReq PDU"); } diff --git a/src/com/android/messaging/ui/common/components/PredictiveBackTransition.kt b/src/com/android/messaging/ui/common/components/PredictiveBackTransition.kt index 744d71784..df78ea85e 100644 --- a/src/com/android/messaging/ui/common/components/PredictiveBackTransition.kt +++ b/src/com/android/messaging/ui/common/components/PredictiveBackTransition.kt @@ -4,52 +4,57 @@ import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.spring -import androidx.compose.animation.fadeIn +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.ui.graphics.TransformOrigin import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.NavigationEvent.SwipeEdge +import kotlin.math.roundToInt -internal fun predictiveBackContentTransform(swipeEdge: Int): ContentTransform { - return predictiveBackEnter() togetherWith predictiveBackExit(swipeEdge = swipeEdge) +internal fun predictiveBackContentTransform( + @SwipeEdge swipeEdge: Int, +): ContentTransform { + return EnterTransition.None togetherWith predictiveBackExit(swipeEdge = swipeEdge) } -private fun predictiveBackEnter(): EnterTransition { - return fadeIn(animationSpec = predictiveBackSpec()) -} - -private fun predictiveBackExit(swipeEdge: Int): ExitTransition { - return scaleOut( +private fun predictiveBackExit( + @SwipeEdge swipeEdge: Int, +): ExitTransition { + val shrink = scaleOut( animationSpec = predictiveBackSpec(), targetScale = PREDICTIVE_BACK_TARGET_SCALE, - transformOrigin = predictiveBackTransformOrigin(swipeEdge = swipeEdge), ) + val push = slideOutHorizontally(animationSpec = predictiveBackSpec()) { fullWidth -> + predictiveBackTranslation(swipeEdge = swipeEdge, fullWidth = fullWidth) + } + + return shrink + push } -private fun predictiveBackTransformOrigin(swipeEdge: Int): TransformOrigin { - val pivotFractionX = when (swipeEdge) { - NavigationEvent.EDGE_LEFT -> TRAILING_PIVOT_FRACTION - NavigationEvent.EDGE_RIGHT -> LEADING_PIVOT_FRACTION - else -> CENTER_PIVOT_FRACTION +internal fun predictiveBackTranslation( + @SwipeEdge swipeEdge: Int, + fullWidth: Int, +): Int { + val slack = fullWidth * (1f - PREDICTIVE_BACK_TARGET_SCALE) / 2f + val travel = (slack - fullWidth * PREDICTIVE_BACK_EDGE_MARGIN_FRACTION).roundToInt() + + return when (swipeEdge) { + NavigationEvent.EDGE_LEFT -> travel + NavigationEvent.EDGE_RIGHT -> -travel + else -> 0 } - - return TransformOrigin( - pivotFractionX = pivotFractionX, - pivotFractionY = CENTER_PIVOT_FRACTION, - ) } -private fun predictiveBackSpec(): FiniteAnimationSpec { - return spring( - dampingRatio = PREDICTIVE_BACK_DAMPING_RATIO, - stiffness = PREDICTIVE_BACK_STIFFNESS, +internal fun predictiveBackSpec(): FiniteAnimationSpec { + return tween( + durationMillis = PREDICTIVE_BACK_DURATION_MILLIS, + easing = LinearEasing, ) } -private const val PREDICTIVE_BACK_TARGET_SCALE = 0.9f -private const val PREDICTIVE_BACK_DAMPING_RATIO = 1.0f -private const val PREDICTIVE_BACK_STIFFNESS = 1600.0f -private const val LEADING_PIVOT_FRACTION = 0f -private const val CENTER_PIVOT_FRACTION = 0.5f -private const val TRAILING_PIVOT_FRACTION = 1f +internal const val PREDICTIVE_BACK_TARGET_SCALE = 0.75f +internal const val PREDICTIVE_BACK_EDGE_MARGIN_FRACTION = 0.025f + +private const val PREDICTIVE_BACK_DURATION_MILLIS = 100 diff --git a/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt b/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt index 80918577f..cf76d5240 100644 --- a/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt +++ b/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material3.Icon @@ -35,6 +36,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -161,6 +164,10 @@ private fun MessageComposeField( trailingIcon = trailingContent, minLines = 1, maxLines = 4, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.ShortMessage, + ), ) overlay?.invoke(this) diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt index b2dc720b2..5c206d1b5 100644 --- a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt @@ -74,13 +74,11 @@ internal fun AddParticipantsScreen( Scaffold( modifier = modifier, - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - titleContentColor = MaterialTheme.colorScheme.onSurface, + containerColor = MaterialTheme.colorScheme.surfaceContainer, ), navigationIcon = { IconButton( diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index 232d362fd..6b1e8279c 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -134,7 +134,7 @@ private fun NewChatScreenContent( selectedGroupRecipients: ImmutableList = persistentListOf(), simSelectorUiState: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), ) { - val screenContainerColor = MaterialTheme.colorScheme.surfaceVariant + val screenContainerColor = MaterialTheme.colorScheme.surfaceContainer Scaffold( modifier = modifier, @@ -177,9 +177,6 @@ private fun NewChatTopAppBar( TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = containerColor, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), navigationIcon = { IconButton( @@ -258,18 +255,18 @@ private fun NewChatRecipientSelectionContent( onContactRecipientClick = onContactLongClick, ), onSelectedRecipientClick = onCreateGroupRecipientClick, + pinnedTopContent = { + NewChatCreateGroupHeader( + isCreatingGroup = isCreatingGroup, + onCreateGroupClick = onCreateGroupClick, + ) + }, simSelectorSlot = { NewChatSimSelectorRow( uiState = simSelectorUiState, onSimSelected = onSimSelected, ) }, - topListContent = { - NewChatRecipientSelectionTopListContent( - isCreatingGroup = isCreatingGroup, - onCreateGroupClick = onCreateGroupClick, - ) - }, ) } diff --git a/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt b/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt index 12feb4f93..6d8d5b8fd 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewGroupButton.kt @@ -13,11 +13,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Group @@ -40,7 +38,7 @@ import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_BUTTON_TEST_T import com.android.messaging.ui.core.MessagingPreviewColumn @Composable -internal fun NewChatRecipientSelectionTopListContent( +internal fun NewChatCreateGroupHeader( isCreatingGroup: Boolean, onCreateGroupClick: () -> Unit, ) { @@ -49,15 +47,12 @@ internal fun NewChatRecipientSelectionTopListContent( enter = newGroupButtonEnterTransition(), exit = newGroupButtonExitTransition(), ) { - Column( - verticalArrangement = Arrangement.spacedBy(space = 12.dp), - ) { - NewGroupButton( - modifier = Modifier.fillMaxWidth(), - onClick = onCreateGroupClick, - ) - Spacer(modifier = Modifier.height(12.dp)) - } + NewGroupButton( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + onClick = onCreateGroupClick, + ) } } @@ -139,9 +134,9 @@ private fun newChatSpatialAnimationSpec(): FiniteAnimationSpec { @PreviewLightDark @Composable -private fun NewChatRecipientSelectionTopListContentPreview() { +private fun NewChatCreateGroupHeaderPreview() { MessagingPreviewColumn { - NewChatRecipientSelectionTopListContent( + NewChatCreateGroupHeader( isCreatingGroup = false, onCreateGroupClick = {}, ) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt index 1ca9f78f5..d625f716d 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PageSize import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AddAPhoto import androidx.compose.material.icons.rounded.Close @@ -37,6 +38,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -399,9 +402,7 @@ private fun ReviewCaptionTextField( mutableStateOf(value = false) } var fieldValue by remember(attachmentContentUri) { - mutableStateOf( - value = captionText.toCaptionTextFieldValue(), - ) + mutableStateOf(value = captionText.toCaptionTextFieldValue()) } LaunchedEffect(attachmentContentUri, captionText) { @@ -450,6 +451,10 @@ private fun ReviewCaptionTextField( ) }, singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.ShortMessage, + ), ) } diff --git a/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsComponents.kt b/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsComponents.kt index 1a48d8838..a623074e9 100644 --- a/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsComponents.kt +++ b/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsComponents.kt @@ -298,7 +298,7 @@ private fun Modifier.copyOnLongPress( .pointerInput(value) { detectTapGestures(onLongPress = { onCopy(value) }) } - .semantics { + .semantics(mergeDescendants = true) { customActions = listOf( CustomAccessibilityAction(label = copyLabel) { onCopy(value) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 08b80b9d9..020dcdbfd 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -18,13 +18,17 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.R import com.android.messaging.data.conversation.model.MessageId import com.android.messaging.ui.conversation.conversationMessageBubbleTestTag import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag @@ -245,12 +249,17 @@ private fun Modifier.conversationMessageBubbleInteractionModifier( onMessageResendClick: () -> Unit, ): Modifier { val hapticFeedback = LocalHapticFeedback.current + val senderAnnouncement = conversationMessageSenderAnnouncement( + message = message, + isSenderLabelVisible = layout.showSender, + ) val bubbleModifier = this .testTag( tag = conversationMessageBubbleTestTag( messageId = message.messageId, ), ) + .conversationMessageSenderSemantics(announcement = senderAnnouncement) .clip(shape = layout.bubbleShape) return when { @@ -274,6 +283,38 @@ private fun Modifier.conversationMessageBubbleInteractionModifier( } } +@Composable +private fun conversationMessageSenderAnnouncement( + message: ConversationMessageUiModel, + isSenderLabelVisible: Boolean, +): String? { + return when { + !message.isIncoming -> stringResource(id = R.string.outgoing_sender_content_description) + isSenderLabelVisible -> null + + else -> { + stringResource( + id = R.string.incoming_sender_content_description, + message + .senderDisplayName + ?.takeIf(String::isNotBlank) + ?: stringResource(id = R.string.unknown_sender), + ) + } + } +} + +private fun Modifier.conversationMessageSenderSemantics(announcement: String?): Modifier { + return when { + announcement == null -> this + else -> { + semantics { + text = AnnotatedString(text = announcement) + } + } + } +} + @Composable internal fun ConversationMessageMetadataRow( message: ConversationMessageUiModel, diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt index 7d8002250..316508f26 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt @@ -52,8 +52,8 @@ internal fun RecipientSelectionContent( onQueryChanged: (String) -> Unit = {}, onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, onSelectedRecipientClick: (SelectedRecipient) -> Unit = {}, + pinnedTopContent: (@Composable () -> Unit)? = null, simSelectorSlot: (@Composable () -> Unit)? = null, - topListContent: (@Composable () -> Unit)? = null, ) { val queryFocusRequester = remember { FocusRequester() } val armedDestination = rememberSaveable { mutableStateOf(null) } @@ -69,6 +69,7 @@ internal fun RecipientSelectionContent( RecipientSelectionContentLayout( modifier = modifier, + pinnedTopContent = pinnedTopContent, queryArea = { RecipientSelectionArmedQueryArea( uiState = uiState, @@ -86,7 +87,6 @@ internal fun RecipientSelectionContent( uiState = uiState, rowDecorators = rowDecorators, armedDestination = armedDestination, - topListContent = topListContent, onLoadMore = onLoadMore, onPrimaryActionClick = onPrimaryActionClick, onRecipientDestinationClick = onRecipientDestinationClick, @@ -101,10 +101,11 @@ private fun RecipientSelectionContentLayout( queryArea: @Composable () -> Unit, contactsArea: @Composable () -> Unit, modifier: Modifier = Modifier, + pinnedTopContent: (@Composable () -> Unit)? = null, ) { Surface( modifier = modifier, - color = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.surfaceContainer, ) { Column( modifier = Modifier @@ -115,6 +116,8 @@ private fun RecipientSelectionContentLayout( queryArea() Spacer(modifier = Modifier.height(12.dp)) + pinnedTopContent?.invoke() + Box(modifier = Modifier.weight(weight = 1f)) { contactsArea() } @@ -194,7 +197,6 @@ private fun RecipientSelectionArmedContactsArea( onLoadMore: () -> Unit, onPrimaryActionClick: () -> Unit, modifier: Modifier = Modifier, - topListContent: (@Composable () -> Unit)? = null, ) { val currentOnPrimaryActionClick = rememberUpdatedState(onPrimaryActionClick) val currentOnRecipientDestinationClick = rememberUpdatedState(onRecipientDestinationClick) @@ -237,7 +239,6 @@ private fun RecipientSelectionArmedContactsArea( onRecipientDestinationLongClick = onRecipientDestinationLongClickWrapped .takeIf { onRecipientDestinationLongClick != null }, emptyStateText = R.string.contact_list_empty_text, - topListContent = topListContent, ) } @@ -290,7 +291,7 @@ private fun RecipientSelectionContentSimSelectorAndTopContentPreview() { onSimSelected = { _ -> }, ) }, - topListContent = { + pinnedTopContent = { PreviewRecipientSelectionContactsTopListContent() }, ) @@ -309,8 +310,8 @@ private fun PreviewRecipientSelectionContent( uiState: RecipientSelectionContentUiState, modifier: Modifier = Modifier.height(height = 560.dp), onRecipientDestinationLongClick: OnRecipientDestinationAction? = { _, _ -> }, + pinnedTopContent: (@Composable () -> Unit)? = null, simSelectorSlot: (@Composable () -> Unit)? = null, - topListContent: (@Composable () -> Unit)? = null, ) { MessagingPreviewTheme { RecipientSelectionContent( @@ -322,8 +323,8 @@ private fun PreviewRecipientSelectionContent( onRecipientDestinationLongClick = onRecipientDestinationLongClick, onSelectedRecipientClick = { _ -> }, onQueryChanged = { _ -> }, + pinnedTopContent = pinnedTopContent, simSelectorSlot = simSelectorSlot, - topListContent = topListContent, ) } } diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCard.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCard.kt index ad6e1d21a..38b7043bf 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCard.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionQueryCard.kt @@ -42,6 +42,10 @@ import com.android.messaging.ui.recipientselection.preview.previewRecipientSelec import kotlinx.collections.immutable.ImmutableList private val recipientSelectionInputRowMinHeight = 32.dp +private val recipientSelectionInputRowPadding = PaddingValues( + horizontal = 16.dp, + vertical = 12.dp, +) internal fun recipientSelectionQueryCardUiState( uiState: RecipientSelectionContentUiState, @@ -232,7 +236,6 @@ private fun RecipientSelectionInputRow( currentKeyboardController.value?.show() } } - val outerPadding = recipientSelectionInputRowPadding(hasChips = recipients.isNotEmpty()) BoxWithConstraints( modifier = Modifier @@ -241,7 +244,7 @@ private fun RecipientSelectionInputRow( enabled = queryFieldUiState.enabled, onTap = onInputAreaTap, ) - .padding(paddingValues = outerPadding), + .padding(paddingValues = recipientSelectionInputRowPadding), ) { RecipientSelectionSelectedRecipientChips( recipients = recipients, @@ -276,17 +279,6 @@ private fun RecipientSelectionInputRow( } } -private fun recipientSelectionInputRowPadding(hasChips: Boolean): PaddingValues { - return when { - hasChips -> { - PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp) - } - else -> { - PaddingValues(horizontal = 16.dp, vertical = 12.dp) - } - } -} - private fun Modifier.recipientSelectionFocusQueryOnUnhandledTap( enabled: Boolean, onTap: () -> Unit, diff --git a/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListViewModel.kt index 066355ded..ba5630db7 100644 --- a/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/archived/ArchivedConversationListViewModel.kt @@ -7,6 +7,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListItem import com.android.messaging.data.conversationlist.model.ConversationListMode import com.android.messaging.data.conversationlist.model.ConversationListSnapshot import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.participant.ResolveContactAction import com.android.messaging.domain.conversation.usecase.participant.model.ResolveContactActionResult import com.android.messaging.ui.contact.model.AddContactRequest @@ -22,6 +23,7 @@ import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiM import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -52,6 +55,8 @@ internal class ArchivedConversationListViewModel @Inject constructor( uiStateMapper: ArchivedConversationListUiStateMapper, private val resolveContactAction: ResolveContactAction, debugFeaturesProvider: DebugFeaturesProvider, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel(), ArchivedConversationListScreenModel { @@ -74,22 +79,24 @@ internal class ArchivedConversationListViewModel @Inject constructor( selectedConversationIds = selectedIds, isDebugEnabled = isDebugEnabled, ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed( - stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, - ), - initialValue = State(), - ) + } + .flowOn(defaultDispatcher) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = State(), + ) init { optimisticSnapshotDelegate.bind( scope = viewModelScope, - mode = ConversationListMode.Archived + mode = ConversationListMode.Archived, ) selectionDelegate.bind( scope = viewModelScope, - snapshot = snapshot + snapshot = snapshot, ) viewModelScope.launch { diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt index f0430b441..08d461354 100644 --- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt +++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt @@ -9,6 +9,7 @@ import com.android.messaging.data.conversationlist.model.ConversationListSnapsho import com.android.messaging.data.conversationlist.repository.ConversationListRepository import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.debug.DebugFeaturesProvider +import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.participant.ResolveContactAction import com.android.messaging.domain.conversation.usecase.participant.model.ResolveContactActionResult import com.android.messaging.ui.contact.model.AddContactRequest @@ -24,6 +25,7 @@ import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiM import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +33,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -52,6 +55,8 @@ internal class ConversationListViewModel @Inject constructor( private val optimisticSnapshotDelegate: ConversationListOptimisticSnapshotDelegate, private val resolveContactAction: ResolveContactAction, private val debugFeaturesProvider: DebugFeaturesProvider, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel(), ConversationListScreenModel { @@ -81,13 +86,15 @@ internal class ConversationListViewModel @Inject constructor( isScrollToTopVisible = isScrollToTopVisible, isDebugEnabled = isDebugEnabled, ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed( - stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, - ), - initialValue = State(), - ) + } + .flowOn(defaultDispatcher) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = State(), + ) init { optimisticSnapshotDelegate.bind( diff --git a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt index b40d1a6e8..7aec91d91 100644 --- a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt +++ b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsComponents.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer @@ -88,16 +89,9 @@ internal fun ConversationHeader( if (title.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.graphicsLayer { - alpha = 1f - collapseProgress() - }, + ExpandedTitle( + title = title, + collapseProgress = collapseProgress, ) } } @@ -181,6 +175,22 @@ internal fun ParticipantItem( ) } +@Composable +private fun ExpandedTitle( + title: String, + collapseProgress: () -> Float, +) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(alpha = 1f - collapseProgress()), + ) +} + @Composable private fun ParticipantRow( participant: ParticipantUiState, diff --git a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt index 3a7f73f44..8daf101a0 100644 --- a/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt +++ b/src/com/android/messaging/ui/conversationsettings/common/ConversationSettingsTopAppBar.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -71,15 +72,9 @@ internal fun ConversationSettingsTopAppBar( Spacer(modifier = Modifier.width(12.dp)) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.graphicsLayer { - alpha = collapseProgress() - }, + CollapsedTitle( + title = title, + collapseProgress = collapseProgress, ) } }, @@ -96,3 +91,18 @@ internal fun ConversationSettingsTopAppBar( ), ) } + +@Composable +private fun CollapsedTitle( + title: String, + collapseProgress: () -> Float, +) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.alpha(alpha = collapseProgress()), + ) +} diff --git a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt index 3a3801898..bde1adcb5 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/ConversationSettingsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -88,6 +89,8 @@ import com.android.messaging.ui.core.MessagingPreviewTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +private const val HEADER_COLLAPSE_DISTANCE_FRACTION = 0.9f + @Composable internal fun ConversationSettingsScreen( screenModel: ConversationSettingsScreenModel, @@ -245,17 +248,44 @@ private fun rememberCollapseProgress( ): ComposeState { return remember { derivedStateOf { - if (listState.firstVisibleItemIndex > 0) { - return@derivedStateOf 1f + val layoutInfo = listState.layoutInfo + val scrollOffset = listState.firstVisibleItemScrollOffset + + when { + listState.firstVisibleItemIndex > 0 -> 1f + else -> headerCollapseProgress( + scrollOffset = scrollOffset, + headerSize = layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0, + maxScrollDistance = layoutInfo.maxScrollDistance(scrollOffset = scrollOffset), + ) } + } + } +} - val headerInfo = listState.layoutInfo.visibleItemsInfo.firstOrNull() - if (headerInfo == null || headerInfo.size == 0) { - return@derivedStateOf 0f - } +internal fun headerCollapseProgress( + scrollOffset: Int, + headerSize: Int, + maxScrollDistance: Int?, +): Float { + val collapseDistance = headerSize * HEADER_COLLAPSE_DISTANCE_FRACTION + val collapsesFully = collapseDistance > 0f && + (maxScrollDistance == null || maxScrollDistance >= collapseDistance) + + return when { + collapsesFully -> (scrollOffset / collapseDistance).coerceIn(0f, 1f) + else -> 0f + } +} + +private fun LazyListLayoutInfo.maxScrollDistance(scrollOffset: Int): Int? { + val lastItem = visibleItemsInfo.lastOrNull() - val scrollOffset = listState.firstVisibleItemScrollOffset.toFloat() - (scrollOffset / headerInfo.size.toFloat()).coerceIn(0f, 1f) + return when { + lastItem == null || lastItem.index != totalItemsCount - 1 -> null + else -> { + val contentEnd = lastItem.offset + lastItem.size + afterContentPadding + scrollOffset + (contentEnd - viewportEndOffset).coerceAtLeast(0) } } } diff --git a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt index f995d76fd..c6bcc1d7d 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/delegate/ConversationSettingsDelegate.kt @@ -8,7 +8,6 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.data.conversationsettings.model.SnoozeOption import com.android.messaging.data.conversationsettings.repository.ConversationNotificationRepository import com.android.messaging.data.conversationsettings.repository.ConversationSettingsRepository -import com.android.messaging.data.subscription.repository.ConversationSimSelectionRepository import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.ParticipantRefresh import com.android.messaging.di.core.ApplicationCoroutineScope @@ -45,7 +44,6 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( private val repository: ConversationSettingsRepository, private val notificationRepository: ConversationNotificationRepository, private val subscriptionsRepository: SubscriptionsRepository, - private val simSelectionRepository: ConversationSimSelectionRepository, private val mapper: ConversationSettingsUiStateMapper, private val conversationsRepository: ConversationsRepository, private val blockedParticipantsRepository: BlockedParticipantsRepository, @@ -91,12 +89,12 @@ internal class ConversationSettingsDelegateImpl @Inject constructor( return combine( settings, subscriptionsRepository.observeActiveSubscriptions(), - simSelectionRepository.observe(id), - ) { data, subscriptions, selfIdOverride -> + subscriptionsRepository.observeDefaultSmsSubscriptionId(), + ) { data, subscriptions, defaultSmsSubscriptionId -> mapper.map( data = data, subscriptions = subscriptions, - selfIdOverride = selfIdOverride, + defaultSmsSubscriptionId = defaultSmsSubscriptionId, ) } } diff --git a/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt b/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt index ba103a69c..545a1c4e7 100644 --- a/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversationsettings/screen/mapper/ConversationSettingsUiStateMapper.kt @@ -2,7 +2,9 @@ package com.android.messaging.ui.conversationsettings.screen.mapper import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.conversationsettings.model.ConversationSettingsData +import com.android.messaging.data.subscription.model.SubId import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.data.subscription.resolveSelectedSubscription import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.domain.conversation.usecase.participant.CanShowOrAddContact import com.android.messaging.domain.conversation.usecase.participant.IsContactSaved @@ -11,14 +13,13 @@ import com.android.messaging.ui.conversationsettings.screen.model.ConversationSe import com.android.messaging.ui.conversationsettings.screen.model.ParticipantUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList internal interface ConversationSettingsUiStateMapper { fun map( data: ConversationSettingsData, - subscriptions: ImmutableList = persistentListOf(), - selfIdOverride: ParticipantId? = null, + subscriptions: ImmutableList, + defaultSmsSubscriptionId: SubId, ): ConversationSettingsUiState } @@ -31,16 +32,17 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( override fun map( data: ConversationSettingsData, subscriptions: ImmutableList, - selfIdOverride: ParticipantId?, + defaultSmsSubscriptionId: SubId, ): ConversationSettingsUiState { val participants = data.participants .map(::toParticipantUiState) .toImmutableList() val otherParticipant = participants.singleOrNull() - val effectiveSelfId = selfIdOverride ?: data.dbSelfParticipantId - val selectedSubscription = subscriptions - .firstOrNull { it.selfParticipantId == effectiveSelfId } - ?: subscriptions.firstOrNull() + val selectedSubscription = resolveSelectedSubscription( + subscriptions = subscriptions, + selectedSelfParticipantId = data.dbSelfParticipantId, + defaultSmsSubscriptionId = defaultSmsSubscriptionId, + ) val canShowContact = otherParticipant?.let { participant -> canShowOrAddContact( @@ -58,7 +60,7 @@ internal class ConversationSettingsUiStateMapperImpl @Inject constructor( isSnoozed = data.isSnoozed, participants = participants, otherParticipant = otherParticipant, - selfParticipantId = effectiveSelfId, + selfParticipantId = data.dbSelfParticipantId, availableSubscriptions = subscriptions, selectedSubscription = selectedSubscription, isSimSwitchAvailable = subscriptions.size > 1, diff --git a/src/com/android/messaging/ui/navigation/AppNavDisplay.kt b/src/com/android/messaging/ui/navigation/AppNavDisplay.kt index d05c9ff02..b5690f546 100644 --- a/src/com/android/messaging/ui/navigation/AppNavDisplay.kt +++ b/src/com/android/messaging/ui/navigation/AppNavDisplay.kt @@ -29,12 +29,17 @@ internal fun AppNavDisplay( val listDetailPaneDecorator = rememberListDetailPaneNavEntryDecorator(showsTwoPanes) val displayCornerDecorator = rememberDisplayCornerNavEntryDecorator() val paneTitleDecorator = rememberPaneTitleNavEntryDecorator() + val enteringBackDecorator = rememberEnteringBackNavEntryDecorator( + canPop = backStack.size > 1, + onBack = onBack, + ) val entryDecorators = remember( saveableStateHolderDecorator, viewModelStoreDecorator, listDetailPaneDecorator, displayCornerDecorator, paneTitleDecorator, + enteringBackDecorator, ) { listOf( saveableStateHolderDecorator, @@ -42,6 +47,7 @@ internal fun AppNavDisplay( listDetailPaneDecorator, displayCornerDecorator, paneTitleDecorator, + enteringBackDecorator, ) } diff --git a/src/com/android/messaging/ui/navigation/EnteringBackDecorator.kt b/src/com/android/messaging/ui/navigation/EnteringBackDecorator.kt new file mode 100644 index 000000000..7fd7f6773 --- /dev/null +++ b/src/com/android/messaging/ui/navigation/EnteringBackDecorator.kt @@ -0,0 +1,43 @@ +package com.android.messaging.ui.navigation + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.EnterExitState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.ui.LocalNavAnimatedContentScope + +/** + * Falls back to ordinary back while an entry is becoming visible. + * + * Predictive back can reuse a cached forward enter transition when it interrupts navigation, + * producing mixed animations. Intercepting it until the entry settles avoids that artifact. + * Root back remains handled by the system. + */ +@Composable +internal fun rememberEnteringBackNavEntryDecorator( + canPop: Boolean, + onBack: () -> Unit, +): NavEntryDecorator { + val currentCanPop by rememberUpdatedState(canPop) + val currentOnBack by rememberUpdatedState(onBack) + + return remember { + NavEntryDecorator { entry -> + val transition = LocalNavAnimatedContentScope.current.transition + + BackHandler( + enabled = currentCanPop && + transition.targetState == EnterExitState.Visible && + transition.currentState != EnterExitState.Visible, + ) { + currentOnBack() + } + + entry.Content() + } + } +} diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java index 373060989..2f083d773 100644 --- a/src/com/android/messaging/util/DebugUtils.java +++ b/src/com/android/messaging/util/DebugUtils.java @@ -31,7 +31,7 @@ import androidx.appcompat.app.AlertDialog; -import static androidx.appcompat.R.style.Theme_AppCompat_Light_Dialog_Alert; +import static androidx.appcompat.R.style.Theme_AppCompat_DayNight_Dialog_Alert; import com.android.messaging.Factory; import com.android.messaging.R; @@ -69,7 +69,7 @@ public class DebugUtils { public static final int DEBUG_SOUND_SERVER_REQUEST = 0; public static final int DEBUG_SOUND_DB_OP = 1; - private static final int DIALOG_THEME = Theme_AppCompat_Light_Dialog_Alert; + private static final int DIALOG_THEME = Theme_AppCompat_DayNight_Dialog_Alert; public static void maybePlayDebugNoise(final Context context, final int sound) { if (sDebugNoise) { diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java index 0fcea874d..e04d67b9f 100644 --- a/src/com/android/messaging/util/ImageUtils.java +++ b/src/com/android/messaging/util/ImageUtils.java @@ -18,7 +18,6 @@ import android.app.ActivityManager; import android.content.ContentResolver; import android.content.Context; -import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.BitmapShader; @@ -29,14 +28,12 @@ import android.graphics.RectF; import android.graphics.Shader.TileMode; import android.net.Uri; -import android.provider.MediaStore; import android.text.TextUtils; import com.android.messaging.Factory; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.media.ImageRequest; -import com.android.messaging.util.Assert.DoesNotRunOnMainThread; import com.android.messaging.util.exif.ExifInterface; import com.google.common.annotations.VisibleForTesting; @@ -202,38 +199,6 @@ public int calculateInSampleSize( return inSampleSize; } - private static final String[] MEDIA_CONTENT_PROJECTION = new String[] { - MediaStore.MediaColumns.MIME_TYPE - }; - - private static final int INDEX_CONTENT_TYPE = 0; - - @DoesNotRunOnMainThread - public static String getContentType(final ContentResolver cr, final Uri uri) { - // Figure out the content type of media. - String contentType = null; - Cursor cursor = null; - if (UriUtil.isMediaStoreUri(uri)) { - try { - cursor = cr.query(uri, MEDIA_CONTENT_PROJECTION, null, null, null); - - if (cursor != null && cursor.moveToFirst()) { - contentType = cursor.getString(INDEX_CONTENT_TYPE); - } - } finally { - if (cursor != null) { - cursor.close(); - } - } - } - if (contentType == null) { - // Last ditch effort to get the content type. Look at the file extension. - contentType = ContentType.getContentTypeFromExtension(uri.toString(), - ContentType.IMAGE_UNSPECIFIED); - } - return contentType; - } - /** * @param context Android context * @param uri Uri to the image data diff --git a/src/com/android/messaging/util/UriUtil.java b/src/com/android/messaging/util/UriUtil.java index 508ff4e5c..f3c7414c6 100644 --- a/src/com/android/messaging/util/UriUtil.java +++ b/src/com/android/messaging/util/UriUtil.java @@ -24,7 +24,6 @@ import android.text.TextUtils; import com.android.messaging.Factory; -import com.android.messaging.datamodel.GalleryBoundCursorLoader; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.util.Assert.DoesNotRunOnMainThread; @@ -124,18 +123,6 @@ public static boolean isMediaStoreUri(final Uri uri) { TextUtils.equals(MEDIA_STORE_URI_KLP, uriAuthority)); } - /** - * Gets the content:// style URI for the given MediaStore row Id in the files table on the - * external volume. - * - * @param id the MediaStore row Id to get the URI for - * @return the URI to the files table on the external storage. - */ - public static Uri getContentUriForMediaStoreId(final long id) { - return MediaStore.Files.getContentUri( - GalleryBoundCursorLoader.MEDIA_SCANNER_VOLUME_EXTERNAL, id); - } - /** * Gets the size in bytes for the content uri. Currently we only support content in the * scratch space.